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:
@@ -0,0 +1,91 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/enmanuel/agents/shell/process"
|
||||
)
|
||||
|
||||
// StatusDiff is published to the "status" topic when an agent's running state changes.
|
||||
type StatusDiff struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
OldStatus bool `json:"old_running"`
|
||||
NewStatus bool `json:"new_running"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
}
|
||||
|
||||
// pollStatus polls StatusAll every 2s and publishes StatusDiff events on changes.
|
||||
func (s *Server) pollStatus(ctx context.Context) {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Seed the previous state map.
|
||||
prev := make(map[string]bool)
|
||||
if statuses, err := s.mgr.StatusAll(); err == nil {
|
||||
for _, st := range statuses {
|
||||
prev[st.ID] = st.Running
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.checkAndPublishDiffs(prev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) checkAndPublishDiffs(prev map[string]bool) {
|
||||
statuses, err := s.mgr.StatusAll()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, st := range statuses {
|
||||
old, known := prev[st.ID]
|
||||
if !known || old != st.Running {
|
||||
s.bus.Publish("status", StatusDiff{
|
||||
AgentID: st.ID,
|
||||
OldStatus: old,
|
||||
NewStatus: st.Running,
|
||||
PID: st.PID,
|
||||
})
|
||||
prev[st.ID] = st.Running
|
||||
}
|
||||
}
|
||||
|
||||
// Handle agents that were removed (disappeared from scan)
|
||||
current := make(map[string]bool, len(statuses))
|
||||
for _, st := range statuses {
|
||||
current[st.ID] = true
|
||||
}
|
||||
for id, wasRunning := range prev {
|
||||
if !current[id] {
|
||||
if wasRunning {
|
||||
s.bus.Publish("status", StatusDiff{
|
||||
AgentID: id,
|
||||
OldStatus: true,
|
||||
NewStatus: false,
|
||||
})
|
||||
}
|
||||
delete(prev, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// agentInfoByID finds AgentInfo by ID in a StatusAll scan.
|
||||
func agentInfoByID(mgr *process.Manager, id string) (*process.AgentInfo, error) {
|
||||
agents, err := mgr.Scan()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, a := range agents {
|
||||
if a.ID == id {
|
||||
return &agents[i], nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
Reference in New Issue
Block a user