Merge pull request 'feat(sessions): workstation table, registration API, and module scaffold' (#170) from feat/workstation-registration into main
Reviewed-on: #170
This commit was merged in pull request #170.
This commit is contained in:
@@ -62,6 +62,7 @@ type Server struct {
|
||||
approvals *db.ItemApprovalRepository
|
||||
workflows map[string]*workflow.Workflow
|
||||
solverResults *db.SolverResultRepository
|
||||
workstations *db.WorkstationRepository
|
||||
}
|
||||
|
||||
// NewServer creates a new API server.
|
||||
@@ -96,6 +97,7 @@ func NewServer(
|
||||
itemMacros := db.NewItemMacroRepository(database)
|
||||
itemApprovals := db.NewItemApprovalRepository(database)
|
||||
solverResults := db.NewSolverResultRepository(database)
|
||||
workstations := db.NewWorkstationRepository(database)
|
||||
seqStore := &dbSequenceStore{db: database, schemas: schemas}
|
||||
partgen := partnum.NewGenerator(schemas, seqStore)
|
||||
|
||||
@@ -130,6 +132,7 @@ func NewServer(
|
||||
approvals: itemApprovals,
|
||||
workflows: workflows,
|
||||
solverResults: solverResults,
|
||||
workstations: workstations,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,14 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
||||
// Workflows (viewer+)
|
||||
r.Get("/workflows", server.HandleListWorkflows)
|
||||
|
||||
// Workstations (gated by sessions module)
|
||||
r.Route("/workstations", func(r chi.Router) {
|
||||
r.Use(server.RequireModule("sessions"))
|
||||
r.Get("/", server.HandleListWorkstations)
|
||||
r.Post("/", server.HandleRegisterWorkstation)
|
||||
r.Delete("/{id}", server.HandleDeleteWorkstation)
|
||||
})
|
||||
|
||||
// Auth endpoints
|
||||
r.Get("/auth/me", server.HandleGetCurrentUser)
|
||||
r.Route("/auth/tokens", func(r chi.Router) {
|
||||
|
||||
138
internal/api/workstation_handlers.go
Normal file
138
internal/api/workstation_handlers.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/kindredsystems/silo/internal/auth"
|
||||
"github.com/kindredsystems/silo/internal/db"
|
||||
)
|
||||
|
||||
// HandleRegisterWorkstation registers or re-registers a workstation for the current user.
|
||||
func (s *Server) HandleRegisterWorkstation(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "validation_error", "name is required")
|
||||
return
|
||||
}
|
||||
|
||||
ws := &db.Workstation{
|
||||
Name: req.Name,
|
||||
UserID: user.ID,
|
||||
Hostname: req.Hostname,
|
||||
}
|
||||
if err := s.workstations.Upsert(ctx, ws); err != nil {
|
||||
s.logger.Error().Err(err).Str("name", req.Name).Msg("failed to register workstation")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to register workstation")
|
||||
return
|
||||
}
|
||||
|
||||
s.broker.Publish("workstation.registered", mustMarshal(map[string]any{
|
||||
"id": ws.ID,
|
||||
"name": ws.Name,
|
||||
"user_id": ws.UserID,
|
||||
"hostname": ws.Hostname,
|
||||
}))
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": ws.ID,
|
||||
"name": ws.Name,
|
||||
"hostname": ws.Hostname,
|
||||
"last_seen": ws.LastSeen.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
"created_at": ws.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
}
|
||||
|
||||
// HandleListWorkstations returns all workstations for the current user.
|
||||
func (s *Server) HandleListWorkstations(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
workstations, err := s.workstations.ListByUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to list workstations")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list workstations")
|
||||
return
|
||||
}
|
||||
|
||||
type wsResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
LastSeen string `json:"last_seen"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
out := make([]wsResponse, len(workstations))
|
||||
for i, ws := range workstations {
|
||||
out[i] = wsResponse{
|
||||
ID: ws.ID,
|
||||
Name: ws.Name,
|
||||
Hostname: ws.Hostname,
|
||||
LastSeen: ws.LastSeen.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
CreatedAt: ws.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// HandleDeleteWorkstation removes a workstation owned by the current user (or any, for admins).
|
||||
func (s *Server) HandleDeleteWorkstation(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
ws, err := s.workstations.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Str("id", id).Msg("failed to get workstation")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get workstation")
|
||||
return
|
||||
}
|
||||
if ws == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Workstation not found")
|
||||
return
|
||||
}
|
||||
|
||||
if ws.UserID != user.ID && user.Role != auth.RoleAdmin {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "You can only delete your own workstations")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.workstations.Delete(ctx, id); err != nil {
|
||||
s.logger.Error().Err(err).Str("id", id).Msg("failed to delete workstation")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete workstation")
|
||||
return
|
||||
}
|
||||
|
||||
s.broker.Publish("workstation.removed", mustMarshal(map[string]any{
|
||||
"id": ws.ID,
|
||||
"name": ws.Name,
|
||||
"user_id": ws.UserID,
|
||||
}))
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -34,6 +34,7 @@ type ModulesConfig struct {
|
||||
Jobs *ModuleToggle `yaml:"jobs"`
|
||||
DAG *ModuleToggle `yaml:"dag"`
|
||||
Solver *ModuleToggle `yaml:"solver"`
|
||||
Sessions *ModuleToggle `yaml:"sessions"`
|
||||
}
|
||||
|
||||
// ModuleToggle holds an optional enabled flag. The pointer allows
|
||||
|
||||
11
internal/db/migrations/022_workstations.sql
Normal file
11
internal/db/migrations/022_workstations.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- 022_workstations.sql — workstation identity for edit sessions
|
||||
|
||||
CREATE TABLE workstations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
hostname TEXT NOT NULL DEFAULT '',
|
||||
last_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(user_id, name)
|
||||
);
|
||||
95
internal/db/workstations.go
Normal file
95
internal/db/workstations.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Workstation represents a registered client machine.
|
||||
type Workstation struct {
|
||||
ID string
|
||||
Name string
|
||||
UserID string
|
||||
Hostname string
|
||||
LastSeen time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// WorkstationRepository provides workstation database operations.
|
||||
type WorkstationRepository struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewWorkstationRepository creates a new workstation repository.
|
||||
func NewWorkstationRepository(db *DB) *WorkstationRepository {
|
||||
return &WorkstationRepository{db: db}
|
||||
}
|
||||
|
||||
// Upsert registers a workstation, updating hostname and last_seen if it already exists.
|
||||
func (r *WorkstationRepository) Upsert(ctx context.Context, w *Workstation) error {
|
||||
return r.db.pool.QueryRow(ctx, `
|
||||
INSERT INTO workstations (name, user_id, hostname)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, name) DO UPDATE
|
||||
SET hostname = EXCLUDED.hostname, last_seen = now()
|
||||
RETURNING id, last_seen, created_at
|
||||
`, w.Name, w.UserID, w.Hostname).Scan(&w.ID, &w.LastSeen, &w.CreatedAt)
|
||||
}
|
||||
|
||||
// GetByID returns a workstation by its ID.
|
||||
func (r *WorkstationRepository) GetByID(ctx context.Context, id string) (*Workstation, error) {
|
||||
w := &Workstation{}
|
||||
err := r.db.pool.QueryRow(ctx, `
|
||||
SELECT id, name, user_id, hostname, last_seen, created_at
|
||||
FROM workstations
|
||||
WHERE id = $1
|
||||
`, id).Scan(&w.ID, &w.Name, &w.UserID, &w.Hostname, &w.LastSeen, &w.CreatedAt)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// ListByUser returns all workstations for a user.
|
||||
func (r *WorkstationRepository) ListByUser(ctx context.Context, userID string) ([]*Workstation, error) {
|
||||
rows, err := r.db.pool.Query(ctx, `
|
||||
SELECT id, name, user_id, hostname, last_seen, created_at
|
||||
FROM workstations
|
||||
WHERE user_id = $1
|
||||
ORDER BY name
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var workstations []*Workstation
|
||||
for rows.Next() {
|
||||
w := &Workstation{}
|
||||
if err := rows.Scan(&w.ID, &w.Name, &w.UserID, &w.Hostname, &w.LastSeen, &w.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workstations = append(workstations, w)
|
||||
}
|
||||
return workstations, rows.Err()
|
||||
}
|
||||
|
||||
// Touch updates a workstation's last_seen timestamp.
|
||||
func (r *WorkstationRepository) Touch(ctx context.Context, id string) error {
|
||||
_, err := r.db.pool.Exec(ctx, `
|
||||
UPDATE workstations SET last_seen = now() WHERE id = $1
|
||||
`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete removes a workstation.
|
||||
func (r *WorkstationRepository) Delete(ctx context.Context, id string) error {
|
||||
_, err := r.db.pool.Exec(ctx, `DELETE FROM workstations WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
@@ -34,6 +34,7 @@ func LoadState(r *Registry, cfg *config.Config, pool *pgxpool.Pool) error {
|
||||
applyToggle(r, Jobs, cfg.Modules.Jobs)
|
||||
applyToggle(r, DAG, cfg.Modules.DAG)
|
||||
applyToggle(r, Solver, cfg.Modules.Solver)
|
||||
applyToggle(r, Sessions, cfg.Modules.Sessions)
|
||||
|
||||
// Step 3: Apply database overrides (highest precedence).
|
||||
if pool != nil {
|
||||
|
||||
@@ -11,6 +11,9 @@ func boolPtr(v bool) *bool { return &v }
|
||||
func TestLoadState_DefaultsOnly(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
cfg := &config.Config{}
|
||||
// Sessions depends on Auth; when auth is disabled via backward-compat
|
||||
// zero value, sessions must also be explicitly disabled.
|
||||
cfg.Modules.Sessions = &config.ModuleToggle{Enabled: boolPtr(false)}
|
||||
|
||||
if err := LoadState(r, cfg, nil); err != nil {
|
||||
t.Fatalf("LoadState: %v", err)
|
||||
@@ -44,8 +47,9 @@ func TestLoadState_BackwardCompat(t *testing.T) {
|
||||
func TestLoadState_YAMLModulesOverrideCompat(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
cfg := &config.Config{}
|
||||
cfg.Auth.Enabled = true // compat says enabled
|
||||
cfg.Modules.Auth = &config.ModuleToggle{Enabled: boolPtr(false)} // explicit says disabled
|
||||
cfg.Auth.Enabled = true // compat says enabled
|
||||
cfg.Modules.Auth = &config.ModuleToggle{Enabled: boolPtr(false)} // explicit says disabled
|
||||
cfg.Modules.Sessions = &config.ModuleToggle{Enabled: boolPtr(false)} // sessions depends on auth
|
||||
|
||||
if err := LoadState(r, cfg, nil); err != nil {
|
||||
t.Fatalf("LoadState: %v", err)
|
||||
|
||||
@@ -22,6 +22,7 @@ const (
|
||||
Jobs = "jobs"
|
||||
DAG = "dag"
|
||||
Solver = "solver"
|
||||
Sessions = "sessions"
|
||||
)
|
||||
|
||||
// ModuleInfo describes a module's metadata.
|
||||
@@ -60,6 +61,7 @@ var builtinModules = []ModuleInfo{
|
||||
{ID: Jobs, Name: "Job Queue", Description: "Async compute jobs, runner management", DependsOn: []string{Auth}},
|
||||
{ID: DAG, Name: "Dependency DAG", Description: "Feature DAG sync, validation states, interference detection", DependsOn: []string{Jobs}},
|
||||
{ID: Solver, Name: "Solver", Description: "Assembly constraint solving via server-side runners", DependsOn: []string{Jobs}},
|
||||
{ID: Sessions, Name: "Sessions", Description: "Workstation registration, edit sessions, and presence tracking", DependsOn: []string{Auth}, DefaultEnabled: true},
|
||||
}
|
||||
|
||||
// NewRegistry creates a registry with all builtin modules set to their default state.
|
||||
|
||||
@@ -137,8 +137,8 @@ func TestAll_ReturnsAllModules(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
all := r.All()
|
||||
|
||||
if len(all) != 11 {
|
||||
t.Errorf("expected 11 modules, got %d", len(all))
|
||||
if len(all) != 12 {
|
||||
t.Errorf("expected 12 modules, got %d", len(all))
|
||||
}
|
||||
|
||||
// Should be sorted by ID.
|
||||
|
||||
Reference in New Issue
Block a user