Add server-side solver service module with REST API endpoints, database
schema, job definitions, and runner result caching.
New files:
- migrations/021_solver_results.sql: solver_results table with upsert constraint
- internal/db/solver_results.go: SolverResultRepository (Upsert, GetByItem, GetByItemRevision)
- internal/api/solver_handlers.go: solver API handlers and maybeCacheSolverResult hook
- jobdefs/assembly-solve.yaml: manual solve job definition
- jobdefs/assembly-validate.yaml: auto-validate on revision creation
- jobdefs/assembly-kinematic.yaml: manual kinematic simulation job
Modified:
- internal/config/config.go: SolverConfig struct with max_context_size_mb, default_timeout
- internal/modules/modules.go, loader.go: register solver module (depends on jobs)
- internal/db/jobs.go: ListSolverJobs helper with definition_name prefix filter
- internal/api/handlers.go: wire SolverResultRepository into Server
- internal/api/routes.go: /api/solver/* routes + /api/items/{partNumber}/solver/results
- internal/api/runner_handlers.go: async result cache hook on job completion
API endpoints:
- POST /api/solver/jobs — submit solver job (editor)
- GET /api/solver/jobs — list solver jobs with filters
- GET /api/solver/jobs/{id} — get solver job status
- POST /api/solver/jobs/{id}/cancel — cancel solver job (editor)
- GET /api/solver/solvers — registry of available solvers
- GET /api/items/{pn}/solver/results — cached results for item
Also fixes pre-existing test compilation errors (missing workflows param
in NewServer calls across 6 test files).
552 lines
15 KiB
Go
552 lines
15 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/kindredsystems/silo/internal/auth"
|
|
"github.com/kindredsystems/silo/internal/db"
|
|
)
|
|
|
|
// SubmitSolveRequest is the JSON body for POST /api/solver/jobs.
|
|
type SubmitSolveRequest struct {
|
|
Solver string `json:"solver"`
|
|
Operation string `json:"operation"`
|
|
Context json.RawMessage `json:"context"`
|
|
Priority *int `json:"priority,omitempty"`
|
|
ItemPartNumber string `json:"item_part_number,omitempty"`
|
|
RevisionNumber *int `json:"revision_number,omitempty"`
|
|
}
|
|
|
|
// SolverJobResponse is the JSON response for solver job creation.
|
|
type SolverJobResponse struct {
|
|
JobID string `json:"job_id"`
|
|
Status string `json:"status"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// SolverResultResponse is the JSON response for cached solver results.
|
|
type SolverResultResponse struct {
|
|
ID string `json:"id"`
|
|
RevisionNumber int `json:"revision_number"`
|
|
JobID *string `json:"job_id,omitempty"`
|
|
Operation string `json:"operation"`
|
|
SolverName string `json:"solver_name"`
|
|
Status string `json:"status"`
|
|
DOF *int `json:"dof,omitempty"`
|
|
Diagnostics json.RawMessage `json:"diagnostics"`
|
|
Placements json.RawMessage `json:"placements"`
|
|
NumFrames int `json:"num_frames"`
|
|
SolveTimeMS *float64 `json:"solve_time_ms,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// operationToDefinition maps solve operations to job definition names.
|
|
var operationToDefinition = map[string]string{
|
|
"solve": "assembly-solve",
|
|
"diagnose": "assembly-validate",
|
|
"kinematic": "assembly-kinematic",
|
|
}
|
|
|
|
// HandleSubmitSolverJob creates a solver job via the existing job queue.
|
|
// POST /api/solver/jobs
|
|
func (s *Server) HandleSubmitSolverJob(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
// Enforce max context size at the HTTP boundary.
|
|
maxBytes := int64(s.cfg.Solver.MaxContextSizeMB) * 1024 * 1024
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
|
|
|
var req SubmitSolveRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
if err.Error() == "http: request body too large" {
|
|
writeError(w, http.StatusRequestEntityTooLarge, "context_too_large",
|
|
"SolveContext exceeds maximum size")
|
|
return
|
|
}
|
|
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body")
|
|
return
|
|
}
|
|
|
|
// Validate operation.
|
|
if req.Operation == "" {
|
|
req.Operation = "solve"
|
|
}
|
|
defName, ok := operationToDefinition[req.Operation]
|
|
if !ok {
|
|
writeError(w, http.StatusBadRequest, "invalid_operation",
|
|
"Operation must be 'solve', 'diagnose', or 'kinematic'")
|
|
return
|
|
}
|
|
|
|
// Context is required.
|
|
if len(req.Context) == 0 {
|
|
writeError(w, http.StatusBadRequest, "missing_context", "SolveContext is required")
|
|
return
|
|
}
|
|
|
|
// Look up job definition.
|
|
def, err := s.jobs.GetDefinition(ctx, defName)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Str("definition", defName).Msg("failed to look up solver job definition")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to look up job definition")
|
|
return
|
|
}
|
|
if def == nil {
|
|
writeError(w, http.StatusNotFound, "definition_not_found",
|
|
"Solver job definition '"+defName+"' not found; ensure job definition YAML is loaded")
|
|
return
|
|
}
|
|
|
|
// Resolve item_part_number → item_id (optional).
|
|
var itemID *string
|
|
if req.ItemPartNumber != "" {
|
|
item, err := s.items.GetByPartNumber(ctx, req.ItemPartNumber)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to get item for solver job")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item")
|
|
return
|
|
}
|
|
if item == nil {
|
|
writeError(w, http.StatusNotFound, "item_not_found",
|
|
"Item '"+req.ItemPartNumber+"' not found")
|
|
return
|
|
}
|
|
itemID = &item.ID
|
|
}
|
|
|
|
// Pack solver-specific data into scope_metadata.
|
|
scopeMeta := map[string]any{
|
|
"solver": req.Solver,
|
|
"operation": req.Operation,
|
|
"context": req.Context,
|
|
}
|
|
if req.RevisionNumber != nil {
|
|
scopeMeta["revision_number"] = *req.RevisionNumber
|
|
}
|
|
if req.ItemPartNumber != "" {
|
|
scopeMeta["item_part_number"] = req.ItemPartNumber
|
|
}
|
|
|
|
priority := def.Priority
|
|
if req.Priority != nil {
|
|
priority = *req.Priority
|
|
}
|
|
|
|
username := ""
|
|
if user := auth.UserFromContext(ctx); user != nil {
|
|
username = user.Username
|
|
}
|
|
|
|
job := &db.Job{
|
|
JobDefinitionID: &def.ID,
|
|
DefinitionName: def.Name,
|
|
Priority: priority,
|
|
ItemID: itemID,
|
|
ScopeMetadata: scopeMeta,
|
|
RunnerTags: def.RunnerTags,
|
|
TimeoutSeconds: def.TimeoutSeconds,
|
|
MaxRetries: def.MaxRetries,
|
|
CreatedBy: &username,
|
|
}
|
|
|
|
// Use solver default timeout if the definition has none.
|
|
if job.TimeoutSeconds == 0 {
|
|
job.TimeoutSeconds = s.cfg.Solver.DefaultTimeout
|
|
}
|
|
|
|
if err := s.jobs.CreateJob(ctx, job); err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to create solver job")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create solver job")
|
|
return
|
|
}
|
|
|
|
s.broker.Publish("job.created", mustMarshal(map[string]any{
|
|
"job_id": job.ID,
|
|
"definition_name": job.DefinitionName,
|
|
"trigger": "manual",
|
|
"item_id": job.ItemID,
|
|
}))
|
|
|
|
writeJSON(w, http.StatusCreated, SolverJobResponse{
|
|
JobID: job.ID,
|
|
Status: job.Status,
|
|
CreatedAt: job.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
})
|
|
}
|
|
|
|
// HandleGetSolverJob returns a single solver job.
|
|
// GET /api/solver/jobs/{jobID}
|
|
func (s *Server) HandleGetSolverJob(w http.ResponseWriter, r *http.Request) {
|
|
jobID := chi.URLParam(r, "jobID")
|
|
|
|
job, err := s.jobs.GetJob(r.Context(), jobID)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to get solver job")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get job")
|
|
return
|
|
}
|
|
if job == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Job not found")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, job)
|
|
}
|
|
|
|
// HandleListSolverJobs lists solver jobs with optional filters.
|
|
// GET /api/solver/jobs
|
|
func (s *Server) HandleListSolverJobs(w http.ResponseWriter, r *http.Request) {
|
|
status := r.URL.Query().Get("status")
|
|
itemPartNumber := r.URL.Query().Get("item")
|
|
operation := r.URL.Query().Get("operation")
|
|
|
|
limit := 20
|
|
if v := r.URL.Query().Get("limit"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 100 {
|
|
limit = n
|
|
}
|
|
}
|
|
offset := 0
|
|
if v := r.URL.Query().Get("offset"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
|
offset = n
|
|
}
|
|
}
|
|
|
|
// Resolve item part number to ID if provided.
|
|
var itemID string
|
|
if itemPartNumber != "" {
|
|
item, err := s.items.GetByPartNumber(r.Context(), itemPartNumber)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to resolve item for solver job list")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item")
|
|
return
|
|
}
|
|
if item == nil {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"jobs": []*db.Job{},
|
|
"total": 0,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
})
|
|
return
|
|
}
|
|
itemID = item.ID
|
|
}
|
|
|
|
jobs, err := s.jobs.ListSolverJobs(r.Context(), status, itemID, operation, limit, offset)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to list solver jobs")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list solver jobs")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"jobs": jobs,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
})
|
|
}
|
|
|
|
// HandleCancelSolverJob cancels a solver job.
|
|
// POST /api/solver/jobs/{jobID}/cancel
|
|
func (s *Server) HandleCancelSolverJob(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
jobID := chi.URLParam(r, "jobID")
|
|
user := auth.UserFromContext(ctx)
|
|
|
|
cancelledBy := "system"
|
|
if user != nil {
|
|
cancelledBy = user.Username
|
|
}
|
|
|
|
if err := s.jobs.CancelJob(ctx, jobID, cancelledBy); err != nil {
|
|
writeError(w, http.StatusBadRequest, "cancel_failed", err.Error())
|
|
return
|
|
}
|
|
|
|
s.broker.Publish("job.cancelled", mustMarshal(map[string]any{
|
|
"job_id": jobID,
|
|
"cancelled_by": cancelledBy,
|
|
}))
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"job_id": jobID,
|
|
"status": "cancelled",
|
|
})
|
|
}
|
|
|
|
// HandleGetSolverRegistry returns available solvers from online runners.
|
|
// GET /api/solver/solvers
|
|
func (s *Server) HandleGetSolverRegistry(w http.ResponseWriter, r *http.Request) {
|
|
runners, err := s.jobs.ListRunners(r.Context())
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to list runners for solver registry")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list runners")
|
|
return
|
|
}
|
|
|
|
type solverInfo struct {
|
|
Name string `json:"name"`
|
|
DisplayName string `json:"display_name,omitempty"`
|
|
Deterministic bool `json:"deterministic,omitempty"`
|
|
SupportedJoints []string `json:"supported_joints,omitempty"`
|
|
RunnerCount int `json:"runner_count"`
|
|
}
|
|
|
|
solverMap := make(map[string]*solverInfo)
|
|
|
|
for _, runner := range runners {
|
|
if runner.Status != "online" {
|
|
continue
|
|
}
|
|
// Check runner has the solver tag.
|
|
hasSolverTag := false
|
|
for _, tag := range runner.Tags {
|
|
if tag == "solver" {
|
|
hasSolverTag = true
|
|
break
|
|
}
|
|
}
|
|
if !hasSolverTag {
|
|
continue
|
|
}
|
|
|
|
// Extract solver capabilities from runner metadata.
|
|
if runner.Metadata == nil {
|
|
continue
|
|
}
|
|
solvers, ok := runner.Metadata["solvers"]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// solvers can be []any (array of solver objects or strings).
|
|
solverList, ok := solvers.([]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
for _, entry := range solverList {
|
|
switch v := entry.(type) {
|
|
case string:
|
|
// Simple string entry: just the solver name.
|
|
if _, exists := solverMap[v]; !exists {
|
|
solverMap[v] = &solverInfo{Name: v}
|
|
}
|
|
solverMap[v].RunnerCount++
|
|
case map[string]any:
|
|
// Rich entry with name, display_name, supported_joints, etc.
|
|
name, _ := v["name"].(string)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
if _, exists := solverMap[name]; !exists {
|
|
info := &solverInfo{Name: name}
|
|
if dn, ok := v["display_name"].(string); ok {
|
|
info.DisplayName = dn
|
|
}
|
|
if det, ok := v["deterministic"].(bool); ok {
|
|
info.Deterministic = det
|
|
}
|
|
if joints, ok := v["supported_joints"].([]any); ok {
|
|
for _, j := range joints {
|
|
if js, ok := j.(string); ok {
|
|
info.SupportedJoints = append(info.SupportedJoints, js)
|
|
}
|
|
}
|
|
}
|
|
solverMap[name] = info
|
|
}
|
|
solverMap[name].RunnerCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
solverList := make([]*solverInfo, 0, len(solverMap))
|
|
for _, info := range solverMap {
|
|
solverList = append(solverList, info)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"solvers": solverList,
|
|
"default_solver": s.cfg.Solver.DefaultSolver,
|
|
})
|
|
}
|
|
|
|
// HandleGetSolverResults returns cached solver results for an item.
|
|
// GET /api/items/{partNumber}/solver/results
|
|
func (s *Server) HandleGetSolverResults(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 for solver results")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
|
return
|
|
}
|
|
if item == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
|
return
|
|
}
|
|
|
|
results, err := s.solverResults.GetByItem(ctx, item.ID)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to list solver results")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list solver results")
|
|
return
|
|
}
|
|
|
|
resp := make([]SolverResultResponse, len(results))
|
|
for i, r := range results {
|
|
diag := json.RawMessage(r.Diagnostics)
|
|
if diag == nil {
|
|
diag = json.RawMessage("[]")
|
|
}
|
|
place := json.RawMessage(r.Placements)
|
|
if place == nil {
|
|
place = json.RawMessage("[]")
|
|
}
|
|
resp[i] = SolverResultResponse{
|
|
ID: r.ID,
|
|
RevisionNumber: r.RevisionNumber,
|
|
JobID: r.JobID,
|
|
Operation: r.Operation,
|
|
SolverName: r.SolverName,
|
|
Status: r.Status,
|
|
DOF: r.DOF,
|
|
Diagnostics: diag,
|
|
Placements: place,
|
|
NumFrames: r.NumFrames,
|
|
SolveTimeMS: r.SolveTimeMS,
|
|
CreatedAt: r.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// maybeCacheSolverResult is called asynchronously after a job completes.
|
|
// It checks if the job is a solver job and upserts the result into solver_results.
|
|
func (s *Server) maybeCacheSolverResult(ctx context.Context, jobID string) {
|
|
job, err := s.jobs.GetJob(ctx, jobID)
|
|
if err != nil || job == nil {
|
|
s.logger.Warn().Err(err).Str("job_id", jobID).Msg("solver result cache: failed to get job")
|
|
return
|
|
}
|
|
|
|
if !strings.HasPrefix(job.DefinitionName, "assembly-") {
|
|
return
|
|
}
|
|
if !s.modules.IsEnabled("solver") {
|
|
return
|
|
}
|
|
if job.ItemID == nil {
|
|
return
|
|
}
|
|
|
|
// Extract fields from scope_metadata.
|
|
operation, _ := job.ScopeMetadata["operation"].(string)
|
|
if operation == "" {
|
|
operation = "solve"
|
|
}
|
|
solverName, _ := job.ScopeMetadata["solver"].(string)
|
|
|
|
var revisionNumber int
|
|
if rn, ok := job.ScopeMetadata["revision_number"].(float64); ok {
|
|
revisionNumber = int(rn)
|
|
}
|
|
|
|
// Extract fields from result.
|
|
if job.Result == nil {
|
|
return
|
|
}
|
|
|
|
status, _ := job.Result["status"].(string)
|
|
if status == "" {
|
|
// Try nested result object.
|
|
if inner, ok := job.Result["result"].(map[string]any); ok {
|
|
status, _ = inner["status"].(string)
|
|
}
|
|
}
|
|
if status == "" {
|
|
status = "Unknown"
|
|
}
|
|
|
|
// Solver name from result takes precedence.
|
|
if sn, ok := job.Result["solver_name"].(string); ok && sn != "" {
|
|
solverName = sn
|
|
}
|
|
if solverName == "" {
|
|
solverName = "unknown"
|
|
}
|
|
|
|
var dof *int
|
|
if d, ok := job.Result["dof"].(float64); ok {
|
|
v := int(d)
|
|
dof = &v
|
|
} else if inner, ok := job.Result["result"].(map[string]any); ok {
|
|
if d, ok := inner["dof"].(float64); ok {
|
|
v := int(d)
|
|
dof = &v
|
|
}
|
|
}
|
|
|
|
var solveTimeMS *float64
|
|
if t, ok := job.Result["solve_time_ms"].(float64); ok {
|
|
solveTimeMS = &t
|
|
}
|
|
|
|
// Marshal diagnostics and placements as raw JSONB.
|
|
var diagnostics, placements []byte
|
|
if d, ok := job.Result["diagnostics"]; ok {
|
|
diagnostics, _ = json.Marshal(d)
|
|
} else if inner, ok := job.Result["result"].(map[string]any); ok {
|
|
if d, ok := inner["diagnostics"]; ok {
|
|
diagnostics, _ = json.Marshal(d)
|
|
}
|
|
}
|
|
if p, ok := job.Result["placements"]; ok {
|
|
placements, _ = json.Marshal(p)
|
|
} else if inner, ok := job.Result["result"].(map[string]any); ok {
|
|
if p, ok := inner["placements"]; ok {
|
|
placements, _ = json.Marshal(p)
|
|
}
|
|
}
|
|
|
|
numFrames := 0
|
|
if nf, ok := job.Result["num_frames"].(float64); ok {
|
|
numFrames = int(nf)
|
|
} else if inner, ok := job.Result["result"].(map[string]any); ok {
|
|
if nf, ok := inner["num_frames"].(float64); ok {
|
|
numFrames = int(nf)
|
|
}
|
|
}
|
|
|
|
result := &db.SolverResult{
|
|
ItemID: *job.ItemID,
|
|
RevisionNumber: revisionNumber,
|
|
JobID: &job.ID,
|
|
Operation: operation,
|
|
SolverName: solverName,
|
|
Status: status,
|
|
DOF: dof,
|
|
Diagnostics: diagnostics,
|
|
Placements: placements,
|
|
NumFrames: numFrames,
|
|
SolveTimeMS: solveTimeMS,
|
|
}
|
|
|
|
if err := s.solverResults.Upsert(ctx, result); err != nil {
|
|
s.logger.Warn().Err(err).Str("job_id", jobID).Msg("solver result cache: failed to upsert")
|
|
} else {
|
|
s.logger.Info().Str("job_id", jobID).Str("operation", operation).Msg("cached solver result")
|
|
}
|
|
}
|