feat(api): HTTP API REST+SSE para gestion remota de agentes (issue 0128)
Nuevo paquete internal/api con servidor HTTP stdlib (sin gin/echo):
- Auth Bearer via AGENTS_API_KEY con subtle.ConstantTimeCompare
- REST: GET /health (sin auth), GET/POST /agents, /agents/{id}, /{id}/{start,stop,restart,logs}
- SSE: /sse/status (broadcast diffs cada 2s) y /sse/agents/{id}/logs (tail -f)
- Pubsub in-memory (TODO: NATS cuando haya 2do cliente)
- Tail de logfiles: retroalimenta ultimos 50KB + poll 200ms para streaming
Integracion en cmd/launcher/main.go:
- Flag --api-port (0=desactivado, 8487 en produccion)
- Flag --api-key (override de AGENTS_API_KEY env var)
- Si apiPort>0 y sin clave, WARN y deshabilita en vez de fallar
Systemd unit en systemd/agents_and_robots.service:
- Restart=always (no on-failure — evita que exit limpio mate el service)
- EnvironmentFile para AGENTS_API_KEY y demas tokens
- WorkingDirectory=/home/ubuntu/CodeProyects/agents_and_robots
app.md v0.2.0:
- port: 8487, health_endpoint: /health (fix drift anterior donde era null)
- e2e_checks: build, tests, smoke_health, smoke_auth
- Documentacion Traefik+DNS pendiente humano post-merge
Tests: 12 tests unitarios en internal/api (auth, health, bus, agents, logs)
Smoke: /health 200, /agents sin auth 401, /agents con key 200 — verificado local
Co-Authored-By: fn-constructor (agent)
This commit is contained in:
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/enmanuel/agents/devagents"
|
||||
"github.com/enmanuel/agents/internal/api"
|
||||
"github.com/enmanuel/agents/internal/config"
|
||||
"github.com/enmanuel/agents/pkg/decision"
|
||||
"github.com/enmanuel/agents/pkg/orchestration"
|
||||
@@ -28,6 +29,7 @@ import (
|
||||
agentlog "github.com/enmanuel/agents/shell/logger"
|
||||
orchshell "github.com/enmanuel/agents/shell/orchestration"
|
||||
shellsecurity "github.com/enmanuel/agents/shell/security"
|
||||
"github.com/enmanuel/agents/shell/process"
|
||||
|
||||
// Blank imports: each agent self-registers its rules via init().
|
||||
_ "github.com/enmanuel/agents/agents/assistant-bot"
|
||||
@@ -46,6 +48,8 @@ func main() {
|
||||
configPaths []string
|
||||
logLevel string
|
||||
logDir string
|
||||
apiPort int
|
||||
apiKey string
|
||||
)
|
||||
|
||||
root := &cobra.Command{
|
||||
@@ -268,6 +272,28 @@ func main() {
|
||||
scanCancel()
|
||||
}
|
||||
|
||||
// ── HTTP API (optional) ──
|
||||
if apiPort > 0 {
|
||||
key := apiKey
|
||||
if key == "" {
|
||||
key = os.Getenv("AGENTS_API_KEY")
|
||||
}
|
||||
if key == "" {
|
||||
logger.Warn("api-port set but AGENTS_API_KEY is empty — HTTP API disabled (set AGENTS_API_KEY in .env)")
|
||||
} else {
|
||||
// Build a process.Manager that reflects the live launcher state.
|
||||
// The manager uses run/ for PID files and agents/*/config.yaml for discovery.
|
||||
mgr := newProcessManager(logDir)
|
||||
srv := api.New(mgr, key, apiPort, logger)
|
||||
go func() {
|
||||
if err := srv.Run(ctx); err != nil {
|
||||
logger.Error("api server stopped", "err", err)
|
||||
}
|
||||
}()
|
||||
logger.Info("http api enabled", "port", apiPort)
|
||||
}
|
||||
}
|
||||
|
||||
// Supervised loop: wait for all agents, and if the parent context is
|
||||
// still alive (i.e. no SIGINT/SIGTERM received), reload them and keep
|
||||
// going. Protects against the launcher exiting cleanly when all
|
||||
@@ -286,6 +312,10 @@ func main() {
|
||||
"Log level: debug | info | warn | error")
|
||||
root.Flags().StringVar(&logDir, "log-dir", "logs",
|
||||
`Log directory (logs/<agent>/YYYY-MM-DD.jsonl). Use "stdout" for console only`)
|
||||
root.Flags().IntVar(&apiPort, "api-port", 0,
|
||||
"HTTP API port (0 = disabled). Requires AGENTS_API_KEY env var.")
|
||||
root.Flags().StringVar(&apiKey, "api-key", "",
|
||||
"HTTP API Bearer key (overrides AGENTS_API_KEY env var)")
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
@@ -364,6 +394,12 @@ func newLogger(level string) *slog.Logger {
|
||||
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: parseLogLevel(level)}))
|
||||
}
|
||||
|
||||
// newProcessManager creates a process.Manager scoped to the current working
|
||||
// directory, used by the HTTP API to reflect the live launcher state.
|
||||
func newProcessManager(logDir string) *process.Manager {
|
||||
return process.NewManager("run", "agents/*/config.yaml", "bin/launcher")
|
||||
}
|
||||
|
||||
// isSpecialConfig checks whether a config path belongs to a middleware special
|
||||
// (e.g. orchestrator) by detecting a "special:" top-level key with a non-empty
|
||||
// id. This avoids config.Load() failing with "agent.id is required" when the
|
||||
|
||||
Reference in New Issue
Block a user