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,90 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// tailLogFile streams new lines appended to path to w (SSE text/plain lines).
|
||||
// Sends existing content first (last 200 lines), then polls for new content.
|
||||
// Blocks until ctx is done.
|
||||
func tailLogFile(ctx context.Context, path string, w http.ResponseWriter, flusher http.Flusher) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
// File may not exist yet (agent hasn't written any logs).
|
||||
// Wait for it to appear.
|
||||
f = waitForFile(ctx, path)
|
||||
if f == nil {
|
||||
return // ctx cancelled before file appeared
|
||||
}
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Seek to end minus ~50 KB to avoid dumping the whole file.
|
||||
// This gives "recent context" without overwhelming the SSE stream.
|
||||
const tailBytes = 50 * 1024
|
||||
info, _ := f.Stat()
|
||||
if info != nil && info.Size() > tailBytes {
|
||||
_, _ = f.Seek(-tailBytes, io.SeekEnd)
|
||||
// Skip incomplete first line
|
||||
r := bufio.NewReader(f)
|
||||
_, _ = r.ReadString('\n')
|
||||
// Emit buffered remainder
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if line != "" {
|
||||
fmt.Fprintf(w, "event: log\ndata: %s\n\n", line)
|
||||
flusher.Flush()
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tail the file: poll for new bytes every 200ms
|
||||
ticker := time.NewTicker(200 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
reader := bufio.NewReader(f)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if line != "" {
|
||||
fmt.Fprintf(w, "event: log\ndata: %s\n\n", line)
|
||||
flusher.Flush()
|
||||
}
|
||||
if err != nil {
|
||||
// io.EOF means no more data right now — wait next tick
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForFile polls until path exists or ctx is done.
|
||||
func waitForFile(ctx context.Context, path string) *os.File {
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
f, err := os.Open(path)
|
||||
if err == nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user