Hub runtime: allocate port 8788, add Go skeleton and Docker deployment

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-10 17:11:52 +11:00
parent 26cb4d324d
commit cd56e69a2f
10 changed files with 185 additions and 7 deletions

3
.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
.git
docs
*.md

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
# Multi-stage build. docs/hub-api.md section 1: Go 1.25, static binary,
# distroless runtime, one container on 192.168.20.13.
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY src/ ./
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /out/hub .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/hub /hub
ENV HUB_PORT=8788
EXPOSE 8788
USER nonroot:nonroot
ENTRYPOINT ["/hub"]

View File

@@ -78,6 +78,7 @@ flowchart LR
- **Paperclip** is at `http://192.168.20.13:3100`. An API key is required. - **Paperclip** is at `http://192.168.20.13:3100`. An API key is required.
- **Home Assistant** is at `http://192.168.20.30:8123`. - **Home Assistant** is at `http://192.168.20.30:8123`.
- **Jervis** (voice) is at `http://192.168.20.13:8501`. - **Jervis** (voice) is at `http://192.168.20.13:8501`.
- **Hub** is at `http://192.168.20.13:8788` (local Docker container).
The design brief is `hub_brief.md`. Project instructions are `AGENTS.md`. The design brief is `hub_brief.md`. Project instructions are `AGENTS.md`.

21
docker-compose.yml Normal file
View File

@@ -0,0 +1,21 @@
services:
hub:
build:
context: .
dockerfile: Dockerfile
image: central-ai-hub:local
container_name: hub
restart: unless-stopped
ports:
- "8788:8788"
environment:
HUB_PORT: "8788"
# Secrets are never hard-coded. The Hub keys (HUB_TOKEN_*, OMNIROUTE_API_KEY)
# come from ~/.config/environment.d/10-secrets.conf. Add only the keys the
# running routes need, when they land (AUD-49+).
healthcheck:
test: ["CMD", "/hub", "-healthcheck"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s

View File

@@ -46,8 +46,7 @@ endpoint with the shape in `docs/ask-contract.md`.
### Base ### Base
- Base URL: `http://192.168.20.13:8787` (port 8787 is the proposed default, - Base URL: `http://192.168.20.13:8788` (the allocated Hub port, `HUB_PORT`; recorded in `docs/outline/02-config-and-env.md`).
`HUB_PORT`). Confirm the port against the house port list before deploy.
- Plain HTTP on the private LAN for v1. Put a TLS reverse proxy in front later. - Plain HTTP on the private LAN for v1. Put a TLS reverse proxy in front later.
- Request and response body: JSON, `application/json; charset=utf-8`. - Request and response body: JSON, `application/json; charset=utf-8`.
- Maximum request body: 64 KiB. Larger bodies get `413`. - Maximum request body: 64 KiB. Larger bodies get `413`.

View File

@@ -30,7 +30,7 @@ hold a personality or a memory. The Hub does not do the destination work.
- **Env File**: `~/.config/environment.d/10-secrets.conf` - **Env File**: `~/.config/environment.d/10-secrets.conf`
- **Config Directory**: `docs/` - **Config Directory**: `docs/`
- **Active Ports**: Hub 8787 (`HUB_PORT`, proposed default — confirm before deploy). - **Active Ports**: Hub 8788 (`HUB_PORT`, allocated 2026-10-10).
## Agent Restart Steps ## Agent Restart Steps

View File

@@ -33,7 +33,9 @@ Relevant keys:
| Home Assistant | 192.168.20.30 | 8123 | Devices and timers. | | Home Assistant | 192.168.20.30 | 8123 | Devices and timers. |
| MQTT | 192.168.20.13 | 1883 | Voice topics. | | MQTT | 192.168.20.13 | 1883 | Voice topics. |
| Apprise | 192.168.20.35 | 8210 | Notifications. | | Apprise | 192.168.20.35 | 8210 | Notifications. |
| Hub | 192.168.20.13 | 8787 | This service. Proposed default (`HUB_PORT`). | | Hub | 192.168.20.13 | 8788 | This service. Allocated 2026-10-10. `HUB_PORT`, default 8788. |
> Port 8787 was only a proposal. It is used by the `headroom` container. Port 8788 was free on 2026-10-10 and is the allocated Hub port.
## Config Paths ## Config Paths

View File

@@ -1,7 +1,7 @@
# Central AI Hub — Code Structure Map # Central AI Hub — Code Structure Map
> **Writing Style**: ASD-STE100 Simplified Technical English (`ste-writing`). > **Writing Style**: ASD-STE100 Simplified Technical English (`ste-writing`).
> **Status**: Planned. The Hub is not built yet. > **Status**: Skeleton built (AUD-48). Routing (AUD-49) is not built yet.
## Planned Folder Layout ## Planned Folder Layout
@@ -10,6 +10,9 @@ central_ai_llm_master/
AGENTS.md project rules for agents AGENTS.md project rules for agents
hub_brief.md agreed design (source of truth) hub_brief.md agreed design (source of truth)
README.md technical readme README.md technical readme
Dockerfile multi-stage build (static binary, distroless)
docker-compose.yml one container, port 8788
.dockerignore build context filter
docs/ docs/
hub-architecture.mmd Mermaid source hub-architecture.mmd Mermaid source
hub-architecture-map.html Archify map hub-architecture-map.html Archify map
@@ -19,8 +22,9 @@ central_ai_llm_master/
ask-contract.md Hub to project interface ask-contract.md Hub to project interface
hub-api.md frozen inbound API (v1) hub-api.md frozen inbound API (v1)
vikunja-tasks.json human task list vikunja-tasks.json human task list
src/ Hub service code (to build) src/ Hub service code
main.go HTTP entry point go.mod module hub
main.go HTTP entry point (GET /health)
hear/ takes voice or text hear/ takes voice or text
understand/ LLM intent routing understand/ LLM intent routing
check/ authorization and approval check/ authorization and approval

3
src/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module hub
go 1.25

132
src/main.go Normal file
View File

@@ -0,0 +1,132 @@
// Command hub is the Central AI Hub service.
//
// Skeleton (AUD-48): it serves GET /health only. POST /ask and POST /approve
// arrive in AUD-49. The wire format is frozen in docs/hub-api.md.
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"time"
)
const (
serviceName = "hub"
serviceVersion = "1"
defaultPort = "8788"
)
var started = time.Now()
type healthResponse struct {
OK bool `json:"ok"`
Service string `json:"service"`
Version string `json:"version"`
UptimeS int64 `json:"uptime_s"`
Checks map[string]string `json:"checks"`
}
type errorBody struct {
Status string `json:"status"`
Error errorInfo `json:"error"`
}
type errorInfo struct {
Code string `json:"code"`
Message string `json:"message"`
}
// port reads HUB_PORT. It falls back to 8788.
func port() string {
if p := os.Getenv("HUB_PORT"); p != "" {
return p
}
return defaultPort
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, code int, errCode, message string) {
writeJSON(w, code, errorBody{
Status: "error",
Error: errorInfo{Code: errCode, Message: message},
})
}
// handleHealth is GET /health. Liveness only. No authentication.
func handleHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "bad_request", "Use GET for this route.")
return
}
// OmniRoute and MQTT are wired in AUD-49, so they read "unknown" here.
writeJSON(w, http.StatusOK, healthResponse{
OK: true,
Service: serviceName,
Version: serviceVersion,
UptimeS: int64(time.Since(started).Seconds()),
Checks: map[string]string{
"omniroute": "unknown",
"mqtt": "unknown",
"log": "ok",
},
})
}
// handleNotFound answers an unknown path with the frozen error envelope.
func handleNotFound(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "Unknown path.")
}
// healthcheck calls this process's own /health. It is the container health probe,
// because the runtime image has no shell and no curl.
func healthcheck() {
url := fmt.Sprintf("http://127.0.0.1:%s/health", port())
client := &http.Client{Timeout: 1 * time.Second}
resp, err := client.Get(url)
if err != nil {
log.Printf("healthcheck failed: %v", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("healthcheck status: %d", resp.StatusCode)
os.Exit(1)
}
os.Exit(0)
}
func main() {
hc := flag.Bool("healthcheck", false, "Call this process's /health and exit.")
flag.Parse()
if *hc {
healthcheck()
return
}
mux := http.NewServeMux()
mux.HandleFunc("/health", handleHealth)
mux.HandleFunc("/", handleNotFound)
srv := &http.Server{
Addr: ":" + port(),
Handler: mux,
ReadHeaderTimeout: 1 * time.Second,
ReadTimeout: 1 * time.Second,
WriteTimeout: 1 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("%s %s listening on :%s", serviceName, serviceVersion, port())
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("%s stopped: %v", serviceName, err)
}
}