Files
Cyrene_v2/cmd/cyrene/main.go
T

116 lines
3.5 KiB
Go

// Command cyrene is the single Cyrene v2 binary. It assembles every subsystem
// (config, logging, database, bus, personas, model adapter, tool dispatcher,
// memory stack, agent loop, orchestration, execution, IoT, channel sessions and
// adapters) into one process and serves the gateway + embedded frontend on a
// single port (:8080), shutting down gracefully on a termination signal.
//
// Build with `go build -ldflags="-s -w" -trimpath -o cyrene ./cmd/cyrene/`.
// The frontend must be built first so the SPA can be embedded:
// `cd frontend/web && npm run build`.
package main
import (
"context"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"time"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/platform"
"git.yeij.top/AskaEth/Cyrene_v2/internal/webui"
)
// envPath is the .env file merged at boot (development only; production uses
// systemd/env_file). A missing .env is not an error.
const envPath = ".env"
// shutdownTimeout bounds how long the graceful teardown may take.
const shutdownTimeout = 15 * time.Second
// externalDistCandidates are the on-disk directories checked (in order) for a
// frontend override before falling back to the embedded dist. The first that
// exists wins. They are relative to the process working directory.
func externalDistCandidates() []string {
return []string{
filepath.Join("frontend", "web", "dist"),
filepath.Join("frontend", "dist"),
}
}
// resolveFrontendFS returns the filesystem serving the SPA: an external dist
// directory when present (deploy-time hot override), otherwise the embedded dist.
func resolveFrontendFS(log *slog.Logger) fs.FS {
for _, dir := range externalDistCandidates() {
if info, err := os.Stat(dir); err == nil && info.IsDir() {
log.Info("serving external frontend dist", "dir", dir)
return os.DirFS(dir)
}
}
log.Info("serving embedded frontend dist")
return webui.Assets()
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "cyrene:", err)
os.Exit(1)
}
}
// run boots the application, starts every service and the gateway, then blocks
// until a termination signal arrives or the gateway fails.
func run() error {
cfg := config.MustLoad(envPath)
format := logger.FormatText
if cfg.Env == "production" {
format = logger.FormatJSON
}
log := logger.New(logger.Options{Level: cfg.LogLevel, Format: string(format)})
log = logger.WithSource(log, "main")
ctx, stop := platform.SignalContext()
defer stop()
a, err := newApp(ctx, cfg, log)
if err != nil {
return err
}
if err := a.automation.Start(ctx); err != nil {
return fmt.Errorf("start automations: %w", err)
}
a.backthink.Start(ctx)
a.log.Info("cyrene subsystems started",
"personas", a.personas.CountPersonas(),
"tools", len(a.caps.List(capreg.KindTool)),
"channels", len(a.caps.List(capreg.KindChannel)))
a.log.Info("cyrene ready", "data_dir", resolveDataDir(cfg))
errCh, err := a.gw.Start(ctx)
if err != nil {
return fmt.Errorf("start gateway: %w", err)
}
select {
case <-ctx.Done():
a.log.Info("cyrene signal received, shutting down")
case gwErr := <-errCh:
if gwErr != nil {
shutCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
a.close(shutCtx)
return fmt.Errorf("gateway failed: %w", gwErr)
}
}
shutCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
a.close(shutCtx)
return nil
}