Compare commits
3 Commits
main
...
feat/edit-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68c9acea5c | ||
|
|
a669327042 | ||
|
|
a851630d85 |
@@ -62,6 +62,8 @@ type Server struct {
|
|||||||
approvals *db.ItemApprovalRepository
|
approvals *db.ItemApprovalRepository
|
||||||
workflows map[string]*workflow.Workflow
|
workflows map[string]*workflow.Workflow
|
||||||
solverResults *db.SolverResultRepository
|
solverResults *db.SolverResultRepository
|
||||||
|
workstations *db.WorkstationRepository
|
||||||
|
editSessions *db.EditSessionRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new API server.
|
// NewServer creates a new API server.
|
||||||
@@ -96,6 +98,8 @@ func NewServer(
|
|||||||
itemMacros := db.NewItemMacroRepository(database)
|
itemMacros := db.NewItemMacroRepository(database)
|
||||||
itemApprovals := db.NewItemApprovalRepository(database)
|
itemApprovals := db.NewItemApprovalRepository(database)
|
||||||
solverResults := db.NewSolverResultRepository(database)
|
solverResults := db.NewSolverResultRepository(database)
|
||||||
|
workstations := db.NewWorkstationRepository(database)
|
||||||
|
editSessions := db.NewEditSessionRepository(database)
|
||||||
seqStore := &dbSequenceStore{db: database, schemas: schemas}
|
seqStore := &dbSequenceStore{db: database, schemas: schemas}
|
||||||
partgen := partnum.NewGenerator(schemas, seqStore)
|
partgen := partnum.NewGenerator(schemas, seqStore)
|
||||||
|
|
||||||
@@ -130,6 +134,8 @@ func NewServer(
|
|||||||
approvals: itemApprovals,
|
approvals: itemApprovals,
|
||||||
workflows: workflows,
|
workflows: workflows,
|
||||||
solverResults: solverResults,
|
solverResults: solverResults,
|
||||||
|
workstations: workstations,
|
||||||
|
editSessions: editSessions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,20 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
|||||||
// Workflows (viewer+)
|
// Workflows (viewer+)
|
||||||
r.Get("/workflows", server.HandleListWorkflows)
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Edit sessions — current user's active sessions (gated by sessions module)
|
||||||
|
r.Route("/edit-sessions", func(r chi.Router) {
|
||||||
|
r.Use(server.RequireModule("sessions"))
|
||||||
|
r.Get("/", server.HandleListUserEditSessions)
|
||||||
|
})
|
||||||
|
|
||||||
// Auth endpoints
|
// Auth endpoints
|
||||||
r.Get("/auth/me", server.HandleGetCurrentUser)
|
r.Get("/auth/me", server.HandleGetCurrentUser)
|
||||||
r.Route("/auth/tokens", func(r chi.Router) {
|
r.Route("/auth/tokens", func(r chi.Router) {
|
||||||
@@ -198,6 +212,19 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Edit sessions (gated by sessions module)
|
||||||
|
r.Route("/edit-sessions", func(r chi.Router) {
|
||||||
|
r.Use(server.RequireModule("sessions"))
|
||||||
|
r.Get("/", server.HandleListItemEditSessions)
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(server.RequireWritable)
|
||||||
|
r.Use(server.RequireRole(auth.RoleEditor))
|
||||||
|
r.Post("/", server.HandleAcquireEditSession)
|
||||||
|
r.Delete("/{sessionID}", server.HandleReleaseEditSession)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(server.RequireWritable)
|
r.Use(server.RequireWritable)
|
||||||
r.Use(server.RequireRole(auth.RoleEditor))
|
r.Use(server.RequireRole(auth.RoleEditor))
|
||||||
|
|||||||
293
internal/api/session_handlers.go
Normal file
293
internal/api/session_handlers.go
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/kindredsystems/silo/internal/auth"
|
||||||
|
"github.com/kindredsystems/silo/internal/db"
|
||||||
|
"github.com/kindredsystems/silo/internal/modules"
|
||||||
|
)
|
||||||
|
|
||||||
|
var validContextLevels = map[string]bool{
|
||||||
|
"sketch": true,
|
||||||
|
"partdesign": true,
|
||||||
|
"assembly": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
type editSessionResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ItemID string `json:"item_id"`
|
||||||
|
PartNumber string `json:"part_number,omitempty"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
WorkstationID string `json:"workstation_id"`
|
||||||
|
ContextLevel string `json:"context_level"`
|
||||||
|
ObjectID *string `json:"object_id"`
|
||||||
|
DependCone []string `json:"dependency_cone"`
|
||||||
|
AcquiredAt string `json:"acquired_at"`
|
||||||
|
LastHeartbeat string `json:"last_heartbeat"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionToResponse(s *db.EditSession, partNumber string) editSessionResponse {
|
||||||
|
cone := s.DependencyCone
|
||||||
|
if cone == nil {
|
||||||
|
cone = []string{}
|
||||||
|
}
|
||||||
|
return editSessionResponse{
|
||||||
|
ID: s.ID,
|
||||||
|
ItemID: s.ItemID,
|
||||||
|
PartNumber: partNumber,
|
||||||
|
UserID: s.UserID,
|
||||||
|
WorkstationID: s.WorkstationID,
|
||||||
|
ContextLevel: s.ContextLevel,
|
||||||
|
ObjectID: s.ObjectID,
|
||||||
|
DependCone: cone,
|
||||||
|
AcquiredAt: s.AcquiredAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||||
|
LastHeartbeat: s.LastHeartbeat.UTC().Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAcquireEditSession acquires an edit session on an item.
|
||||||
|
// POST /api/items/{partNumber}/edit-sessions
|
||||||
|
func (s *Server) HandleAcquireEditSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
partNumber := chi.URLParam(r, "partNumber")
|
||||||
|
|
||||||
|
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get item")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
WorkstationID string `json:"workstation_id"`
|
||||||
|
ContextLevel string `json:"context_level"`
|
||||||
|
ObjectID *string `json:"object_id"`
|
||||||
|
DependencyCone []string `json:"dependency_cone"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.WorkstationID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "validation_error", "workstation_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validContextLevels[req.ContextLevel] {
|
||||||
|
writeError(w, http.StatusBadRequest, "validation_error", "context_level must be sketch, partdesign, or assembly")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no dependency cone provided and DAG module is enabled, attempt to compute it.
|
||||||
|
depCone := req.DependencyCone
|
||||||
|
if len(depCone) == 0 && req.ObjectID != nil && s.modules.IsEnabled(modules.DAG) {
|
||||||
|
node, nodeErr := s.dag.GetNodeByKey(ctx, item.ID, item.CurrentRevision, *req.ObjectID)
|
||||||
|
if nodeErr == nil && node != nil {
|
||||||
|
coneNodes, coneErr := s.dag.GetForwardCone(ctx, node.ID)
|
||||||
|
if coneErr == nil {
|
||||||
|
depCone = make([]string, len(coneNodes))
|
||||||
|
for i, n := range coneNodes {
|
||||||
|
depCone[i] = n.NodeKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session := &db.EditSession{
|
||||||
|
ItemID: item.ID,
|
||||||
|
UserID: user.ID,
|
||||||
|
WorkstationID: req.WorkstationID,
|
||||||
|
ContextLevel: req.ContextLevel,
|
||||||
|
ObjectID: req.ObjectID,
|
||||||
|
DependencyCone: depCone,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.editSessions.Acquire(ctx, session); err != nil {
|
||||||
|
// Check for unique constraint violation (hard interference).
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
s.writeConflictResponse(w, r, item.ID, req.ContextLevel, req.ObjectID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.Error().Err(err).Msg("failed to acquire edit session")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to acquire edit session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.broker.PublishToItem(item.ID, "edit.session_acquired", mustMarshal(map[string]any{
|
||||||
|
"session_id": session.ID,
|
||||||
|
"item_id": item.ID,
|
||||||
|
"part_number": partNumber,
|
||||||
|
"user": user.Username,
|
||||||
|
"workstation": req.WorkstationID,
|
||||||
|
"context_level": session.ContextLevel,
|
||||||
|
"object_id": session.ObjectID,
|
||||||
|
}))
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, sessionToResponse(session, partNumber))
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeConflictResponse builds a 409 response with holder info.
|
||||||
|
func (s *Server) writeConflictResponse(w http.ResponseWriter, r *http.Request, itemID, contextLevel string, objectID *string) {
|
||||||
|
ctx := r.Context()
|
||||||
|
conflict, err := s.editSessions.GetConflict(ctx, itemID, contextLevel, objectID)
|
||||||
|
if err != nil || conflict == nil {
|
||||||
|
writeError(w, http.StatusConflict, "hard_interference", "Another user is editing this object")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up holder's username and workstation name.
|
||||||
|
holderUser := "unknown"
|
||||||
|
if u, err := s.auth.GetUserByID(ctx, conflict.UserID); err == nil && u != nil {
|
||||||
|
holderUser = u.Username
|
||||||
|
}
|
||||||
|
holderWS := conflict.WorkstationID
|
||||||
|
if ws, err := s.workstations.GetByID(ctx, conflict.WorkstationID); err == nil && ws != nil {
|
||||||
|
holderWS = ws.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
objDesc := contextLevel
|
||||||
|
if objectID != nil {
|
||||||
|
objDesc = *objectID
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusConflict, map[string]any{
|
||||||
|
"error": "hard_interference",
|
||||||
|
"holder": map[string]any{
|
||||||
|
"user": holderUser,
|
||||||
|
"workstation": holderWS,
|
||||||
|
"context_level": conflict.ContextLevel,
|
||||||
|
"object_id": conflict.ObjectID,
|
||||||
|
"acquired_at": conflict.AcquiredAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||||
|
},
|
||||||
|
"message": fmt.Sprintf("%s is currently editing %s", holderUser, objDesc),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleReleaseEditSession releases an edit session.
|
||||||
|
// DELETE /api/items/{partNumber}/edit-sessions/{sessionID}
|
||||||
|
func (s *Server) HandleReleaseEditSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
partNumber := chi.URLParam(r, "partNumber")
|
||||||
|
sessionID := chi.URLParam(r, "sessionID")
|
||||||
|
|
||||||
|
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get item")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := s.editSessions.GetByID(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Str("session_id", sessionID).Msg("failed to get edit session")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get edit session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if session == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Edit session not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.UserID != user.ID && user.Role != auth.RoleAdmin {
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden", "You can only release your own edit sessions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.editSessions.Release(ctx, sessionID); err != nil {
|
||||||
|
s.logger.Error().Err(err).Str("session_id", sessionID).Msg("failed to release edit session")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to release edit session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.broker.PublishToItem(item.ID, "edit.session_released", mustMarshal(map[string]any{
|
||||||
|
"session_id": session.ID,
|
||||||
|
"item_id": item.ID,
|
||||||
|
"part_number": partNumber,
|
||||||
|
"user": user.Username,
|
||||||
|
"context_level": session.ContextLevel,
|
||||||
|
"object_id": session.ObjectID,
|
||||||
|
}))
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleListItemEditSessions lists active edit sessions for an item.
|
||||||
|
// GET /api/items/{partNumber}/edit-sessions
|
||||||
|
func (s *Server) HandleListItemEditSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
partNumber := chi.URLParam(r, "partNumber")
|
||||||
|
|
||||||
|
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get item")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions, err := s.editSessions.ListForItem(ctx, item.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to list edit sessions")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list edit sessions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]editSessionResponse, len(sessions))
|
||||||
|
for i, sess := range sessions {
|
||||||
|
out[i] = sessionToResponse(sess, partNumber)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleListUserEditSessions lists active edit sessions for the current user.
|
||||||
|
// GET /api/edit-sessions
|
||||||
|
func (s *Server) HandleListUserEditSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions, err := s.editSessions.ListForUser(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to list edit sessions")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list edit sessions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]editSessionResponse, len(sessions))
|
||||||
|
for i, sess := range sessions {
|
||||||
|
out[i] = sessionToResponse(sess, "")
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
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"`
|
Jobs *ModuleToggle `yaml:"jobs"`
|
||||||
DAG *ModuleToggle `yaml:"dag"`
|
DAG *ModuleToggle `yaml:"dag"`
|
||||||
Solver *ModuleToggle `yaml:"solver"`
|
Solver *ModuleToggle `yaml:"solver"`
|
||||||
|
Sessions *ModuleToggle `yaml:"sessions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModuleToggle holds an optional enabled flag. The pointer allows
|
// ModuleToggle holds an optional enabled flag. The pointer allows
|
||||||
|
|||||||
222
internal/db/edit_sessions.go
Normal file
222
internal/db/edit_sessions.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EditSession represents an active editing context.
|
||||||
|
type EditSession struct {
|
||||||
|
ID string
|
||||||
|
ItemID string
|
||||||
|
UserID string
|
||||||
|
WorkstationID string
|
||||||
|
ContextLevel string
|
||||||
|
ObjectID *string
|
||||||
|
DependencyCone []string
|
||||||
|
AcquiredAt time.Time
|
||||||
|
LastHeartbeat time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditSessionRepository provides edit session database operations.
|
||||||
|
type EditSessionRepository struct {
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEditSessionRepository creates a new edit session repository.
|
||||||
|
func NewEditSessionRepository(db *DB) *EditSessionRepository {
|
||||||
|
return &EditSessionRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire inserts a new edit session. Returns a unique constraint error
|
||||||
|
// if another session already holds the same (item_id, context_level, object_id).
|
||||||
|
func (r *EditSessionRepository) Acquire(ctx context.Context, s *EditSession) error {
|
||||||
|
return r.db.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO edit_sessions (item_id, user_id, workstation_id, context_level, object_id, dependency_cone)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, acquired_at, last_heartbeat
|
||||||
|
`, s.ItemID, s.UserID, s.WorkstationID, s.ContextLevel, s.ObjectID, s.DependencyCone).
|
||||||
|
Scan(&s.ID, &s.AcquiredAt, &s.LastHeartbeat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release deletes an edit session by ID.
|
||||||
|
func (r *EditSessionRepository) Release(ctx context.Context, id string) error {
|
||||||
|
_, err := r.db.pool.Exec(ctx, `DELETE FROM edit_sessions WHERE id = $1`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseForWorkstation deletes all sessions for a workstation, returning
|
||||||
|
// the released sessions so callers can publish SSE notifications.
|
||||||
|
func (r *EditSessionRepository) ReleaseForWorkstation(ctx context.Context, workstationID string) ([]EditSession, error) {
|
||||||
|
rows, err := r.db.pool.Query(ctx, `
|
||||||
|
DELETE FROM edit_sessions
|
||||||
|
WHERE workstation_id = $1
|
||||||
|
RETURNING id, item_id, user_id, workstation_id, context_level, object_id, dependency_cone, acquired_at, last_heartbeat
|
||||||
|
`, workstationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sessions []EditSession
|
||||||
|
for rows.Next() {
|
||||||
|
var s EditSession
|
||||||
|
if err := rows.Scan(&s.ID, &s.ItemID, &s.UserID, &s.WorkstationID,
|
||||||
|
&s.ContextLevel, &s.ObjectID, &s.DependencyCone,
|
||||||
|
&s.AcquiredAt, &s.LastHeartbeat); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sessions = append(sessions, s)
|
||||||
|
}
|
||||||
|
return sessions, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID returns an edit session by its ID.
|
||||||
|
func (r *EditSessionRepository) GetByID(ctx context.Context, id string) (*EditSession, error) {
|
||||||
|
s := &EditSession{}
|
||||||
|
err := r.db.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, item_id, user_id, workstation_id, context_level, object_id,
|
||||||
|
dependency_cone, acquired_at, last_heartbeat
|
||||||
|
FROM edit_sessions
|
||||||
|
WHERE id = $1
|
||||||
|
`, id).Scan(&s.ID, &s.ItemID, &s.UserID, &s.WorkstationID,
|
||||||
|
&s.ContextLevel, &s.ObjectID, &s.DependencyCone,
|
||||||
|
&s.AcquiredAt, &s.LastHeartbeat)
|
||||||
|
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListForItem returns all active edit sessions for an item.
|
||||||
|
func (r *EditSessionRepository) ListForItem(ctx context.Context, itemID string) ([]*EditSession, error) {
|
||||||
|
rows, err := r.db.pool.Query(ctx, `
|
||||||
|
SELECT id, item_id, user_id, workstation_id, context_level, object_id,
|
||||||
|
dependency_cone, acquired_at, last_heartbeat
|
||||||
|
FROM edit_sessions
|
||||||
|
WHERE item_id = $1
|
||||||
|
ORDER BY acquired_at
|
||||||
|
`, itemID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sessions []*EditSession
|
||||||
|
for rows.Next() {
|
||||||
|
s := &EditSession{}
|
||||||
|
if err := rows.Scan(&s.ID, &s.ItemID, &s.UserID, &s.WorkstationID,
|
||||||
|
&s.ContextLevel, &s.ObjectID, &s.DependencyCone,
|
||||||
|
&s.AcquiredAt, &s.LastHeartbeat); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sessions = append(sessions, s)
|
||||||
|
}
|
||||||
|
return sessions, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListForUser returns all active edit sessions for a user.
|
||||||
|
func (r *EditSessionRepository) ListForUser(ctx context.Context, userID string) ([]*EditSession, error) {
|
||||||
|
rows, err := r.db.pool.Query(ctx, `
|
||||||
|
SELECT id, item_id, user_id, workstation_id, context_level, object_id,
|
||||||
|
dependency_cone, acquired_at, last_heartbeat
|
||||||
|
FROM edit_sessions
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY acquired_at
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sessions []*EditSession
|
||||||
|
for rows.Next() {
|
||||||
|
s := &EditSession{}
|
||||||
|
if err := rows.Scan(&s.ID, &s.ItemID, &s.UserID, &s.WorkstationID,
|
||||||
|
&s.ContextLevel, &s.ObjectID, &s.DependencyCone,
|
||||||
|
&s.AcquiredAt, &s.LastHeartbeat); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sessions = append(sessions, s)
|
||||||
|
}
|
||||||
|
return sessions, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TouchHeartbeat updates last_heartbeat for all sessions of a workstation.
|
||||||
|
func (r *EditSessionRepository) TouchHeartbeat(ctx context.Context, workstationID string) error {
|
||||||
|
_, err := r.db.pool.Exec(ctx, `
|
||||||
|
UPDATE edit_sessions SET last_heartbeat = now() WHERE workstation_id = $1
|
||||||
|
`, workstationID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpireStale deletes sessions whose last_heartbeat is older than the given
|
||||||
|
// timeout, returning the expired sessions for SSE notification.
|
||||||
|
func (r *EditSessionRepository) ExpireStale(ctx context.Context, timeout time.Duration) ([]EditSession, error) {
|
||||||
|
rows, err := r.db.pool.Query(ctx, `
|
||||||
|
DELETE FROM edit_sessions
|
||||||
|
WHERE last_heartbeat < now() - $1::interval
|
||||||
|
RETURNING id, item_id, user_id, workstation_id, context_level, object_id, dependency_cone, acquired_at, last_heartbeat
|
||||||
|
`, timeout.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sessions []EditSession
|
||||||
|
for rows.Next() {
|
||||||
|
var s EditSession
|
||||||
|
if err := rows.Scan(&s.ID, &s.ItemID, &s.UserID, &s.WorkstationID,
|
||||||
|
&s.ContextLevel, &s.ObjectID, &s.DependencyCone,
|
||||||
|
&s.AcquiredAt, &s.LastHeartbeat); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sessions = append(sessions, s)
|
||||||
|
}
|
||||||
|
return sessions, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConflict returns the existing session holding a given (item, context_level, object_id)
|
||||||
|
// slot, for building 409 conflict responses.
|
||||||
|
func (r *EditSessionRepository) GetConflict(ctx context.Context, itemID, contextLevel string, objectID *string) (*EditSession, error) {
|
||||||
|
s := &EditSession{}
|
||||||
|
var query string
|
||||||
|
var args []any
|
||||||
|
|
||||||
|
if objectID != nil {
|
||||||
|
query = `
|
||||||
|
SELECT id, item_id, user_id, workstation_id, context_level, object_id,
|
||||||
|
dependency_cone, acquired_at, last_heartbeat
|
||||||
|
FROM edit_sessions
|
||||||
|
WHERE item_id = $1 AND context_level = $2 AND object_id = $3
|
||||||
|
`
|
||||||
|
args = []any{itemID, contextLevel, *objectID}
|
||||||
|
} else {
|
||||||
|
query = `
|
||||||
|
SELECT id, item_id, user_id, workstation_id, context_level, object_id,
|
||||||
|
dependency_cone, acquired_at, last_heartbeat
|
||||||
|
FROM edit_sessions
|
||||||
|
WHERE item_id = $1 AND context_level = $2 AND object_id IS NULL
|
||||||
|
`
|
||||||
|
args = []any{itemID, contextLevel}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.db.pool.QueryRow(ctx, query, args...).Scan(
|
||||||
|
&s.ID, &s.ItemID, &s.UserID, &s.WorkstationID,
|
||||||
|
&s.ContextLevel, &s.ObjectID, &s.DependencyCone,
|
||||||
|
&s.AcquiredAt, &s.LastHeartbeat)
|
||||||
|
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
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)
|
||||||
|
);
|
||||||
17
internal/db/migrations/023_edit_sessions.sql
Normal file
17
internal/db/migrations/023_edit_sessions.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- 023_edit_sessions.sql — active editing context tracking
|
||||||
|
|
||||||
|
CREATE TABLE edit_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
workstation_id UUID NOT NULL REFERENCES workstations(id) ON DELETE CASCADE,
|
||||||
|
context_level TEXT NOT NULL CHECK (context_level IN ('sketch', 'partdesign', 'assembly')),
|
||||||
|
object_id TEXT,
|
||||||
|
dependency_cone TEXT[],
|
||||||
|
acquired_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
last_heartbeat TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_edit_sessions_item ON edit_sessions(item_id);
|
||||||
|
CREATE INDEX idx_edit_sessions_user ON edit_sessions(user_id);
|
||||||
|
CREATE UNIQUE INDEX idx_edit_sessions_active ON edit_sessions(item_id, context_level, object_id);
|
||||||
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, Jobs, cfg.Modules.Jobs)
|
||||||
applyToggle(r, DAG, cfg.Modules.DAG)
|
applyToggle(r, DAG, cfg.Modules.DAG)
|
||||||
applyToggle(r, Solver, cfg.Modules.Solver)
|
applyToggle(r, Solver, cfg.Modules.Solver)
|
||||||
|
applyToggle(r, Sessions, cfg.Modules.Sessions)
|
||||||
|
|
||||||
// Step 3: Apply database overrides (highest precedence).
|
// Step 3: Apply database overrides (highest precedence).
|
||||||
if pool != nil {
|
if pool != nil {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ func boolPtr(v bool) *bool { return &v }
|
|||||||
func TestLoadState_DefaultsOnly(t *testing.T) {
|
func TestLoadState_DefaultsOnly(t *testing.T) {
|
||||||
r := NewRegistry()
|
r := NewRegistry()
|
||||||
cfg := &config.Config{}
|
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 {
|
if err := LoadState(r, cfg, nil); err != nil {
|
||||||
t.Fatalf("LoadState: %v", err)
|
t.Fatalf("LoadState: %v", err)
|
||||||
@@ -44,8 +47,9 @@ func TestLoadState_BackwardCompat(t *testing.T) {
|
|||||||
func TestLoadState_YAMLModulesOverrideCompat(t *testing.T) {
|
func TestLoadState_YAMLModulesOverrideCompat(t *testing.T) {
|
||||||
r := NewRegistry()
|
r := NewRegistry()
|
||||||
cfg := &config.Config{}
|
cfg := &config.Config{}
|
||||||
cfg.Auth.Enabled = true // compat says enabled
|
cfg.Auth.Enabled = true // compat says enabled
|
||||||
cfg.Modules.Auth = &config.ModuleToggle{Enabled: boolPtr(false)} // explicit says disabled
|
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 {
|
if err := LoadState(r, cfg, nil); err != nil {
|
||||||
t.Fatalf("LoadState: %v", err)
|
t.Fatalf("LoadState: %v", err)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const (
|
|||||||
Jobs = "jobs"
|
Jobs = "jobs"
|
||||||
DAG = "dag"
|
DAG = "dag"
|
||||||
Solver = "solver"
|
Solver = "solver"
|
||||||
|
Sessions = "sessions"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ModuleInfo describes a module's metadata.
|
// 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: 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: 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: 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.
|
// 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()
|
r := NewRegistry()
|
||||||
all := r.All()
|
all := r.All()
|
||||||
|
|
||||||
if len(all) != 11 {
|
if len(all) != 12 {
|
||||||
t.Errorf("expected 11 modules, got %d", len(all))
|
t.Errorf("expected 12 modules, got %d", len(all))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should be sorted by ID.
|
// Should be sorted by ID.
|
||||||
|
|||||||
Reference in New Issue
Block a user