package main import ( "context" "fmt" "io/fs" "log" "net/http" "os" "os/signal" "syscall" "time" hwlab "git.georgsen.dk/hwlab" "git.georgsen.dk/hwlab/internal/advisor" "git.georgsen.dk/hwlab/internal/ai" "git.georgsen.dk/hwlab/internal/api" "git.georgsen.dk/hwlab/internal/api/handlers" "git.georgsen.dk/hwlab/internal/config" "git.georgsen.dk/hwlab/internal/inventory" "git.georgsen.dk/hwlab/internal/netbox" "git.georgsen.dk/hwlab/internal/printer" "git.georgsen.dk/hwlab/internal/queue" "git.georgsen.dk/hwlab/internal/research" "git.georgsen.dk/hwlab/internal/store" "git.georgsen.dk/hwlab/internal/usb" ) func main() { cfg, err := config.Load() if err != nil { log.Fatalf("config: %v", err) } staticFS, err := fs.Sub(hwlab.StaticFiles, "web/dist") if err != nil { log.Fatalf("embed: %v", err) } // Context for graceful shutdown ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() // NetBox client (required — fatal if misconfigured) nbClient, err := netbox.NewClient(cfg.NetBoxURL, cfg.NetBoxToken) if err != nil { log.Fatalf("netbox client: %v", err) } // AI tier clients and orchestrator tier1 := ai.NewTierClient(cfg.AI.Tier1) tier2 := ai.NewTierClient(cfg.AI.Tier2) orch := ai.NewOrchestrator(tier1, tier2, cfg.AI.ConfidenceThreshold) // Catalog updater catalogUpdater := inventory.NewCatalogUpdater(nbClient) // Start write-ahead queue worker (non-fatal if DragonFlyDB unavailable). // waqForHandler is typed as the interface so that a nil *WAQ is not wrapped in a non-nil interface. var waqForHandler handlers.IntakeWAQ waqInstance, waqErr := queue.NewWAQ(cfg.DragonflyURL) if waqErr != nil { log.Printf("WARNING: WAQ unavailable (%v) — NetBox operations will not be queued during downtime", waqErr) } else { nbHandler := queue.NewNetBoxOpHandler(nbClient) retryInterval := time.Duration(cfg.WAQRetryIntervalSeconds) * time.Second go waqInstance.RunWorker(ctx, nbHandler, cfg.WAQMaxAttempts, retryInterval) defer waqInstance.Close() waqForHandler = waqInstance log.Printf("WAQ worker started") } // USB Manager — polls for device connect/disconnect events. // Start with 2-second poll interval (production default). usbManager := usb.NewManager(2 * time.Second) go usbManager.Start(ctx) defer usbManager.Stop() // Printer driver — MockDriver until PRT Qutie hardware arrives (2026-04-13). // TODO(hardware): replace with printer.NewPrtQutieDriver(9600) after characterization. mockDriver := printer.NewMockDriver() if err := mockDriver.Connect(); err != nil { log.Printf("WARNING: mock printer connect: %v", err) } // Intake handler — waqForHandler and mockDriver may be nil; handler handles nil gracefully. intakeHandler := handlers.NewIntakeHandler( orch, nbClient, catalogUpdater, waqForHandler, cfg.NetBoxDefaultDeviceTypeID, cfg.NetBoxDefaultRoleID, cfg.NetBoxDefaultSiteID, cfg.AI.QuickAddEnabled, cfg.AI.QuickAddThreshold, mockDriver, ) inventoryHandler := handlers.NewInventoryHandler(nbClient) labelHandler := handlers.NewLabelHandler(nbClient, mockDriver) usbEventsHandler := handlers.NewUSBEventsHandler(usbManager) testHandler := handlers.NewTestHandler(nbClient, mockDriver) // Store + advisor — non-fatal if DB unavailable (advisor degrades gracefully). var advisorHandler *advisor.AdvisorHandler dbDSN := os.Getenv("HWLAB_DATABASE_URL") if dbDSN != "" { s, storeErr := store.NewStore(ctx, dbDSN) if storeErr != nil { log.Printf("WARNING: store unavailable (%v) — advisor endpoints will be disabled", storeErr) } else { defer s.Close() if migErr := store.RunMigrations(ctx, s.Pool()); migErr != nil { log.Printf("WARNING: store migrations failed (%v) — advisor endpoints may misbehave", migErr) } ctxBuilder := advisor.NewInventoryContextBuilder(nbClient) advisorHandler = advisor.NewAdvisorHandler(s, ctxBuilder, cfg.AI) log.Printf("Advisor handler ready") } } else { log.Printf("HWLAB_DATABASE_URL not set — advisor endpoints disabled") } // Research agent — enriches needs_research items via SearXNG + Tier 2 LLM. searxngClient := research.NewSearXNGClient(cfg.SearXNGURL) researchAgent := research.NewAgent(nbClient, searxngClient, tier2, catalogUpdater) go researchAgent.Start(ctx, 10*time.Minute) researchHandler := handlers.NewResearchHandler(researchAgent) searchHandler := handlers.NewSearchHandler(nbClient, tier1) // Wire USB Manager events to cable tester driver when a RoleCableTester device connects. // Currently a no-op stub — wires the plumbing for Phase 5 hardware integration. go func() { for evt := range usbManager.Events() { if evt.Spec.Role == usb.RoleCableTester { log.Printf("cable tester connected: %s", evt.VIDPID) // TODO(hardware): construct tester driver for evt.VIDPID, // call driver.Connect(), then testHandler.AttachStream(driver.Stream()) _ = testHandler } } }() router := api.NewRouter(staticFS, intakeHandler, inventoryHandler, labelHandler, usbEventsHandler, testHandler, advisorHandler, researchHandler, searchHandler) addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) log.Printf("HWLab starting on %s", addr) srv := &http.Server{Addr: addr, Handler: router} go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("server: %v", err) } }() // Wait for shutdown signal <-ctx.Done() log.Println("Shutting down...") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { log.Printf("server shutdown: %v", err) } log.Println("Shutdown complete") }