Files
noema/cmd/noemad/main.go
T
Andy Dixon 85d769b245 feat: add immutable cognitive event store, live streams and event explorer
Phase 3. Events are the append-only record of everything a mind does
(ADR-0002). pkg/events defines the closed type vocabulary, categories,
evidence references and the observation payload with explicit trust
levels. The store validates and appends inside the caller's transaction,
publishes to a bounded fan-out bus only after commit, queries by seq
cursor with type/category/source/salience/confidence/time/search
filters, and walks provenance: cause, parents, children, cited evidence,
derived events and correlation groups. Durable per-consumer cursors never
move backwards.

Server-Sent Events streams (per mind and global) subscribe before
replaying history after Last-Event-ID so nothing is missed, apply the
same filter vocabulary live, and heartbeat. Operators can inject
observations tagged with operator trust for demos and tests.

UI: live thought stream with filters and paging, forensic event page
with causal chain and evidence navigation, dashboard important events.
Personality and status changes now leave operator_action events in the
mind's own history. CLI: event inspect/list/tail, observe.
2026-06-17 20:02:22 +01:00

138 lines
4.4 KiB
Go

// Command noemad is the Noema daemon: API server, web UI, scheduler,
// integration manager and cognitive workers in one process.
package main
import (
"context"
"errors"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/andydixon/noema/internal/api"
"github.com/andydixon/noema/internal/app"
"github.com/andydixon/noema/internal/audit"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/config"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/observability"
"github.com/andydixon/noema/internal/scheduler"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/internal/ui"
"github.com/andydixon/noema/internal/version"
"github.com/andydixon/noema/migrations"
"github.com/andydixon/noema/pkg/ids"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "noemad:", err)
os.Exit(1)
}
}
func run() error {
var (
cfgPath = flag.String("config", "", "path to JSON config file (optional)")
migrate = flag.Bool("migrate", true, "apply pending migrations at start")
show = flag.Bool("version", false, "print version and exit")
)
flag.Parse()
if *show {
fmt.Println(version.Version)
return nil
}
cfg, err := config.Load(*cfgPath, os.LookupEnv)
if err != nil {
return err
}
log := observability.NewLogger(os.Stderr, cfg.LogLevel, cfg.LogFormat)
log.Info("starting noemad", "version", version.Version, "env", cfg.Env, "addr", cfg.HTTPAddr)
log.Debug("configuration", "config", fmt.Sprintf("%+v", cfg.Redacted()))
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
db, err := storage.Open(ctx, cfg.DatabaseURL)
if err != nil {
return err
}
defer db.Close()
if *migrate {
applied, err := storage.MigrateUp(ctx, db, migrations.FS)
if err != nil {
return fmt.Errorf("migrate: %w", err)
}
if len(applied) > 0 {
log.Info("applied migrations", "migrations", applied)
}
}
metrics := observability.NewMetrics()
authSvc := auth.NewService(db, cfg.SessionIdleTTL, cfg.SessionAbsoluteTTL)
auditLog := audit.New(db, log)
deps := app.Deps{Config: cfg, Log: log, DB: db, Auth: authSvc, Audit: auditLog, Metrics: metrics, Minds: minds.NewStore(db), Events: evstore.NewStore(db, evstore.NewBus(200), metrics), Migrations: migrations.FS, Started: time.Now()}
if err := bootstrapAdmin(ctx, deps); err != nil {
return err
}
var srv *api.Server
uiHandler, err := ui.New(deps, func(r *http.Request) any { return srv.Status(r.Context()) },
func(r *http.Request, id ids.ID) (api.EventContext, error) { return srv.Context(r, id) })
if err != nil {
return err
}
srv = api.New(deps, uiHandler)
group := scheduler.NewGroup(ctx, log)
httpSrv := &http.Server{Addr: cfg.HTTPAddr, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, MaxHeaderBytes: 64 << 10}
group.Go("http", func(ctx context.Context) error {
errc := make(chan error, 1)
go func() { errc <- httpSrv.ListenAndServe() }() // owned: joined via errc below
select {
case err := <-errc:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return httpSrv.Shutdown(shutdownCtx)
}
})
group.Every("auth.sweep", 5*time.Minute, func(context.Context) error { authSvc.SweepLimiter(); return nil })
group.Every("auth.purge", time.Hour, authSvc.PurgeExpiredSessions)
log.Info("noemad ready", "url", "http://"+cfg.HTTPAddr)
<-ctx.Done()
log.Info("shutting down")
return group.Stop()
}
// bootstrapAdmin creates the first administrator from the environment when the
// users table is empty, so headless deployments can start without the setup page.
func bootstrapAdmin(ctx context.Context, d app.Deps) error {
n, err := d.Auth.CountUsers(ctx)
if err != nil {
return err
}
if n > 0 || d.Config.BootstrapAdminPassword == "" {
return nil
}
u, err := d.Auth.CreateUser(ctx, d.Config.BootstrapAdminUser, d.Config.BootstrapAdminPassword, auth.RoleAdministrator)
if err != nil {
return fmt.Errorf("bootstrap admin: %w", err)
}
d.Audit.Log(ctx, "user.bootstrap", "user", u.ID.String(), map[string]any{"username": u.Username, "via": "environment"})
d.Log.Info("created bootstrap administrator", "username", u.Username)
return nil
}