Compare commits
17 Commits
feat/kc-de
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a8cbf64e4 | |||
|
|
21c592bcb2 | ||
| 82cdd221ef | |||
|
|
e7da3ee94d | ||
| cbde4141eb | |||
|
|
a851630d85 | ||
| e5cae28a8c | |||
|
|
5f144878d6 | ||
| ed1ac45e12 | |||
|
|
88d1ab1f97 | ||
|
|
12ecffdabe | ||
| e260c175bf | |||
|
|
bae06da1a1 | ||
| 161c1c1e62 | |||
| df0fc13193 | |||
|
|
6e6c9c2c75 | ||
| 98be1fa78c |
@@ -5,10 +5,6 @@
|
|||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
POSTGRES_PASSWORD=silodev
|
POSTGRES_PASSWORD=silodev
|
||||||
|
|
||||||
# MinIO
|
|
||||||
MINIO_ACCESS_KEY=silominio
|
|
||||||
MINIO_SECRET_KEY=silominiosecret
|
|
||||||
|
|
||||||
# OpenLDAP
|
# OpenLDAP
|
||||||
LDAP_ADMIN_PASSWORD=ldapadmin
|
LDAP_ADMIN_PASSWORD=ldapadmin
|
||||||
LDAP_USERS=siloadmin
|
LDAP_USERS=siloadmin
|
||||||
|
|||||||
17
Makefile
17
Makefile
@@ -1,8 +1,7 @@
|
|||||||
.PHONY: build run test test-integration clean migrate fmt lint \
|
.PHONY: build run test test-integration clean migrate fmt lint \
|
||||||
docker-build docker-up docker-down docker-logs docker-ps \
|
docker-build docker-up docker-down docker-logs docker-ps \
|
||||||
docker-clean docker-rebuild \
|
docker-clean docker-rebuild \
|
||||||
web-install web-dev web-build \
|
web-install web-dev web-build
|
||||||
migrate-storage
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Local Development
|
# Local Development
|
||||||
@@ -57,13 +56,6 @@ tidy:
|
|||||||
migrate:
|
migrate:
|
||||||
./scripts/init-db.sh
|
./scripts/init-db.sh
|
||||||
|
|
||||||
# Build and run MinIO → filesystem migration tool
|
|
||||||
# Usage: make migrate-storage DEST=/opt/silo/data [ARGS="--dry-run --verbose"]
|
|
||||||
migrate-storage:
|
|
||||||
go build -o migrate-storage ./cmd/migrate-storage
|
|
||||||
@echo "Built ./migrate-storage"
|
|
||||||
@echo "Run: ./migrate-storage -config <config.yaml> -dest <dir> [-dry-run] [-verbose]"
|
|
||||||
|
|
||||||
# Connect to database (requires psql)
|
# Connect to database (requires psql)
|
||||||
db-shell:
|
db-shell:
|
||||||
PGPASSWORD=$${SILO_DB_PASSWORD:-silodev} psql -h $${SILO_DB_HOST:-localhost} -U $${SILO_DB_USER:-silo} -d $${SILO_DB_NAME:-silo}
|
PGPASSWORD=$${SILO_DB_PASSWORD:-silodev} psql -h $${SILO_DB_HOST:-localhost} -U $${SILO_DB_USER:-silo} -d $${SILO_DB_NAME:-silo}
|
||||||
@@ -76,7 +68,7 @@ db-shell:
|
|||||||
docker-build:
|
docker-build:
|
||||||
docker build -t silo:latest -f build/package/Dockerfile .
|
docker build -t silo:latest -f build/package/Dockerfile .
|
||||||
|
|
||||||
# Start the full stack (postgres + minio + silo)
|
# Start the full stack (postgres + silo)
|
||||||
docker-up:
|
docker-up:
|
||||||
docker compose -f deployments/docker-compose.yaml up -d
|
docker compose -f deployments/docker-compose.yaml up -d
|
||||||
|
|
||||||
@@ -103,9 +95,6 @@ docker-logs-silo:
|
|||||||
docker-logs-postgres:
|
docker-logs-postgres:
|
||||||
docker compose -f deployments/docker-compose.yaml logs -f postgres
|
docker compose -f deployments/docker-compose.yaml logs -f postgres
|
||||||
|
|
||||||
docker-logs-minio:
|
|
||||||
docker compose -f deployments/docker-compose.yaml logs -f minio
|
|
||||||
|
|
||||||
# Show running containers
|
# Show running containers
|
||||||
docker-ps:
|
docker-ps:
|
||||||
docker compose -f deployments/docker-compose.yaml ps
|
docker compose -f deployments/docker-compose.yaml ps
|
||||||
@@ -175,7 +164,7 @@ help:
|
|||||||
@echo ""
|
@echo ""
|
||||||
@echo "Docker:"
|
@echo "Docker:"
|
||||||
@echo " docker-build - Build Docker image"
|
@echo " docker-build - Build Docker image"
|
||||||
@echo " docker-up - Start full stack (postgres + minio + silo)"
|
@echo " docker-up - Start full stack (postgres + silo)"
|
||||||
@echo " docker-down - Stop the stack"
|
@echo " docker-down - Stop the stack"
|
||||||
@echo " docker-clean - Stop and remove volumes (deletes data)"
|
@echo " docker-clean - Stop and remove volumes (deletes data)"
|
||||||
@echo " docker-logs - View all logs"
|
@echo " docker-logs - View all logs"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ silo/
|
|||||||
│ ├── ods/ # ODS spreadsheet library
|
│ ├── ods/ # ODS spreadsheet library
|
||||||
│ ├── partnum/ # Part number generation
|
│ ├── partnum/ # Part number generation
|
||||||
│ ├── schema/ # YAML schema parsing
|
│ ├── schema/ # YAML schema parsing
|
||||||
│ ├── storage/ # MinIO file storage
|
│ ├── storage/ # Filesystem storage
|
||||||
│ └── testutil/ # Test helpers
|
│ └── testutil/ # Test helpers
|
||||||
├── web/ # React SPA (Vite + TypeScript)
|
├── web/ # React SPA (Vite + TypeScript)
|
||||||
│ └── src/
|
│ └── src/
|
||||||
@@ -55,7 +55,7 @@ silo/
|
|||||||
|
|
||||||
See the **[Installation Guide](docs/INSTALL.md)** for complete setup instructions.
|
See the **[Installation Guide](docs/INSTALL.md)** for complete setup instructions.
|
||||||
|
|
||||||
**Docker Compose (quickest — includes PostgreSQL, MinIO, OpenLDAP, and Silo):**
|
**Docker Compose (quickest — includes PostgreSQL, OpenLDAP, and Silo):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/setup-docker.sh
|
./scripts/setup-docker.sh
|
||||||
@@ -65,7 +65,7 @@ docker compose -f deployments/docker-compose.allinone.yaml up -d
|
|||||||
**Development (local Go + Docker services):**
|
**Development (local Go + Docker services):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make docker-up # Start PostgreSQL + MinIO in Docker
|
make docker-up # Start PostgreSQL in Docker
|
||||||
make run # Run silo locally with Go
|
make run # Run silo locally with Go
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,288 +0,0 @@
|
|||||||
// Command migrate-storage downloads files from MinIO and writes them to the
|
|
||||||
// local filesystem. It is a one-shot migration tool for moving off MinIO.
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// migrate-storage -config config.yaml -dest /opt/silo/data [-dry-run] [-verbose]
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/kindredsystems/silo/internal/config"
|
|
||||||
"github.com/kindredsystems/silo/internal/db"
|
|
||||||
"github.com/kindredsystems/silo/internal/storage"
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fileEntry represents a single file to migrate.
|
|
||||||
type fileEntry struct {
|
|
||||||
key string
|
|
||||||
versionID string // MinIO version ID; empty if not versioned
|
|
||||||
size int64 // expected size from DB; 0 if unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
configPath := flag.String("config", "config.yaml", "Path to configuration file")
|
|
||||||
dest := flag.String("dest", "", "Destination root directory (required)")
|
|
||||||
dryRun := flag.Bool("dry-run", false, "Preview what would be migrated without downloading")
|
|
||||||
verbose := flag.Bool("verbose", false, "Log every file, not just errors and summary")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
|
|
||||||
|
|
||||||
if *dest == "" {
|
|
||||||
logger.Fatal().Msg("-dest is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load config (reuses existing config for DB + MinIO credentials).
|
|
||||||
cfg, err := config.Load(*configPath)
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatal().Err(err).Msg("failed to load configuration")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Connect to PostgreSQL.
|
|
||||||
database, err := db.Connect(ctx, db.Config{
|
|
||||||
Host: cfg.Database.Host,
|
|
||||||
Port: cfg.Database.Port,
|
|
||||||
Name: cfg.Database.Name,
|
|
||||||
User: cfg.Database.User,
|
|
||||||
Password: cfg.Database.Password,
|
|
||||||
SSLMode: cfg.Database.SSLMode,
|
|
||||||
MaxConnections: cfg.Database.MaxConnections,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatal().Err(err).Msg("failed to connect to database")
|
|
||||||
}
|
|
||||||
defer database.Close()
|
|
||||||
logger.Info().Msg("connected to database")
|
|
||||||
|
|
||||||
// Connect to MinIO.
|
|
||||||
store, err := storage.Connect(ctx, storage.Config{
|
|
||||||
Endpoint: cfg.Storage.Endpoint,
|
|
||||||
AccessKey: cfg.Storage.AccessKey,
|
|
||||||
SecretKey: cfg.Storage.SecretKey,
|
|
||||||
Bucket: cfg.Storage.Bucket,
|
|
||||||
UseSSL: cfg.Storage.UseSSL,
|
|
||||||
Region: cfg.Storage.Region,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatal().Err(err).Msg("failed to connect to MinIO")
|
|
||||||
}
|
|
||||||
logger.Info().Str("bucket", cfg.Storage.Bucket).Msg("connected to MinIO")
|
|
||||||
|
|
||||||
// Collect all file references from the database.
|
|
||||||
entries, err := collectEntries(ctx, logger, database)
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatal().Err(err).Msg("failed to collect file entries from database")
|
|
||||||
}
|
|
||||||
logger.Info().Int("total", len(entries)).Msg("file entries found")
|
|
||||||
|
|
||||||
if len(entries) == 0 {
|
|
||||||
logger.Info().Msg("nothing to migrate")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migrate.
|
|
||||||
var migrated, skipped, failed int
|
|
||||||
start := time.Now()
|
|
||||||
|
|
||||||
for i, e := range entries {
|
|
||||||
destPath := filepath.Join(*dest, e.key)
|
|
||||||
|
|
||||||
// Check if already migrated.
|
|
||||||
if info, err := os.Stat(destPath); err == nil {
|
|
||||||
if e.size > 0 && info.Size() == e.size {
|
|
||||||
if *verbose {
|
|
||||||
logger.Info().Str("key", e.key).Msg("skipped (already exists)")
|
|
||||||
}
|
|
||||||
skipped++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Size mismatch or unknown size — re-download.
|
|
||||||
}
|
|
||||||
|
|
||||||
if *dryRun {
|
|
||||||
logger.Info().
|
|
||||||
Str("key", e.key).
|
|
||||||
Int64("size", e.size).
|
|
||||||
Str("version", e.versionID).
|
|
||||||
Msgf("[%d/%d] would migrate", i+1, len(entries))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := migrateFile(ctx, store, e, destPath); err != nil {
|
|
||||||
logger.Error().Err(err).Str("key", e.key).Msg("failed to migrate")
|
|
||||||
failed++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
migrated++
|
|
||||||
if *verbose {
|
|
||||||
logger.Info().
|
|
||||||
Str("key", e.key).
|
|
||||||
Int64("size", e.size).
|
|
||||||
Msgf("[%d/%d] migrated", i+1, len(entries))
|
|
||||||
} else if (i+1)%50 == 0 {
|
|
||||||
logger.Info().Msgf("progress: %d/%d", i+1, len(entries))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
ev := logger.Info().
|
|
||||||
Int("total", len(entries)).
|
|
||||||
Int("migrated", migrated).
|
|
||||||
Int("skipped", skipped).
|
|
||||||
Int("failed", failed).
|
|
||||||
Dur("elapsed", elapsed)
|
|
||||||
if *dryRun {
|
|
||||||
ev.Msg("dry run complete")
|
|
||||||
} else {
|
|
||||||
ev.Msg("migration complete")
|
|
||||||
}
|
|
||||||
|
|
||||||
if failed > 0 {
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectEntries queries the database for all file references across the three
|
|
||||||
// storage domains: revision files, item file attachments, and item thumbnails.
|
|
||||||
// It deduplicates by key.
|
|
||||||
func collectEntries(ctx context.Context, logger zerolog.Logger, database *db.DB) ([]fileEntry, error) {
|
|
||||||
pool := database.Pool()
|
|
||||||
seen := make(map[string]struct{})
|
|
||||||
var entries []fileEntry
|
|
||||||
|
|
||||||
add := func(key, versionID string, size int64) {
|
|
||||||
if key == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, ok := seen[key]; ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seen[key] = struct{}{}
|
|
||||||
entries = append(entries, fileEntry{key: key, versionID: versionID, size: size})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Revision files.
|
|
||||||
rows, err := pool.Query(ctx,
|
|
||||||
`SELECT file_key, COALESCE(file_version, ''), COALESCE(file_size, 0)
|
|
||||||
FROM revisions WHERE file_key IS NOT NULL`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("querying revisions: %w", err)
|
|
||||||
}
|
|
||||||
for rows.Next() {
|
|
||||||
var key, version string
|
|
||||||
var size int64
|
|
||||||
if err := rows.Scan(&key, &version, &size); err != nil {
|
|
||||||
rows.Close()
|
|
||||||
return nil, fmt.Errorf("scanning revision row: %w", err)
|
|
||||||
}
|
|
||||||
add(key, version, size)
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("iterating revisions: %w", err)
|
|
||||||
}
|
|
||||||
logger.Info().Int("count", len(entries)).Msg("revision files found")
|
|
||||||
|
|
||||||
// 2. Item file attachments.
|
|
||||||
countBefore := len(entries)
|
|
||||||
rows, err = pool.Query(ctx,
|
|
||||||
`SELECT object_key, size FROM item_files`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("querying item_files: %w", err)
|
|
||||||
}
|
|
||||||
for rows.Next() {
|
|
||||||
var key string
|
|
||||||
var size int64
|
|
||||||
if err := rows.Scan(&key, &size); err != nil {
|
|
||||||
rows.Close()
|
|
||||||
return nil, fmt.Errorf("scanning item_files row: %w", err)
|
|
||||||
}
|
|
||||||
add(key, "", size)
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("iterating item_files: %w", err)
|
|
||||||
}
|
|
||||||
logger.Info().Int("count", len(entries)-countBefore).Msg("item file attachments found")
|
|
||||||
|
|
||||||
// 3. Item thumbnails.
|
|
||||||
countBefore = len(entries)
|
|
||||||
rows, err = pool.Query(ctx,
|
|
||||||
`SELECT thumbnail_key FROM items WHERE thumbnail_key IS NOT NULL`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("querying item thumbnails: %w", err)
|
|
||||||
}
|
|
||||||
for rows.Next() {
|
|
||||||
var key string
|
|
||||||
if err := rows.Scan(&key); err != nil {
|
|
||||||
rows.Close()
|
|
||||||
return nil, fmt.Errorf("scanning thumbnail row: %w", err)
|
|
||||||
}
|
|
||||||
add(key, "", 0)
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("iterating thumbnails: %w", err)
|
|
||||||
}
|
|
||||||
logger.Info().Int("count", len(entries)-countBefore).Msg("item thumbnails found")
|
|
||||||
|
|
||||||
return entries, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// migrateFile downloads a single file from MinIO and writes it atomically to destPath.
|
|
||||||
func migrateFile(ctx context.Context, store *storage.Storage, e fileEntry, destPath string) error {
|
|
||||||
// Ensure parent directory exists.
|
|
||||||
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
|
||||||
return fmt.Errorf("creating directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download from MinIO.
|
|
||||||
var reader io.ReadCloser
|
|
||||||
var err error
|
|
||||||
if e.versionID != "" {
|
|
||||||
reader, err = store.GetVersion(ctx, e.key, e.versionID)
|
|
||||||
} else {
|
|
||||||
reader, err = store.Get(ctx, e.key)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("downloading from MinIO: %w", err)
|
|
||||||
}
|
|
||||||
defer reader.Close()
|
|
||||||
|
|
||||||
// Write to temp file then rename for atomicity.
|
|
||||||
tmpPath := destPath + ".tmp"
|
|
||||||
f, err := os.Create(tmpPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := io.Copy(f, reader); err != nil {
|
|
||||||
f.Close()
|
|
||||||
os.Remove(tmpPath)
|
|
||||||
return fmt.Errorf("writing file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := f.Close(); err != nil {
|
|
||||||
os.Remove(tmpPath)
|
|
||||||
return fmt.Errorf("closing temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.Rename(tmpPath, destPath); err != nil {
|
|
||||||
os.Remove(tmpPath)
|
|
||||||
return fmt.Errorf("renaming temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"github.com/kindredsystems/silo/internal/modules"
|
"github.com/kindredsystems/silo/internal/modules"
|
||||||
"github.com/kindredsystems/silo/internal/schema"
|
"github.com/kindredsystems/silo/internal/schema"
|
||||||
"github.com/kindredsystems/silo/internal/storage"
|
"github.com/kindredsystems/silo/internal/storage"
|
||||||
|
"github.com/kindredsystems/silo/internal/workflow"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,7 +45,6 @@ func main() {
|
|||||||
Str("host", cfg.Server.Host).
|
Str("host", cfg.Server.Host).
|
||||||
Int("port", cfg.Server.Port).
|
Int("port", cfg.Server.Port).
|
||||||
Str("database", cfg.Database.Host).
|
Str("database", cfg.Database.Host).
|
||||||
Str("storage", cfg.Storage.Endpoint).
|
|
||||||
Msg("starting silo server")
|
Msg("starting silo server")
|
||||||
|
|
||||||
// Connect to database
|
// Connect to database
|
||||||
@@ -64,40 +64,17 @@ func main() {
|
|||||||
defer database.Close()
|
defer database.Close()
|
||||||
logger.Info().Msg("connected to database")
|
logger.Info().Msg("connected to database")
|
||||||
|
|
||||||
// Connect to storage (optional - may be externally managed)
|
// Connect to storage (optional — requires root_dir to be set)
|
||||||
var store storage.FileStore
|
var store storage.FileStore
|
||||||
switch cfg.Storage.Backend {
|
if cfg.Storage.Filesystem.RootDir != "" {
|
||||||
case "minio", "":
|
|
||||||
if cfg.Storage.Endpoint != "" {
|
|
||||||
s, connErr := storage.Connect(ctx, storage.Config{
|
|
||||||
Endpoint: cfg.Storage.Endpoint,
|
|
||||||
AccessKey: cfg.Storage.AccessKey,
|
|
||||||
SecretKey: cfg.Storage.SecretKey,
|
|
||||||
Bucket: cfg.Storage.Bucket,
|
|
||||||
UseSSL: cfg.Storage.UseSSL,
|
|
||||||
Region: cfg.Storage.Region,
|
|
||||||
})
|
|
||||||
if connErr != nil {
|
|
||||||
logger.Warn().Err(connErr).Msg("failed to connect to storage - file operations disabled")
|
|
||||||
} else {
|
|
||||||
store = s
|
|
||||||
logger.Info().Msg("connected to storage")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.Info().Msg("storage not configured - file operations disabled")
|
|
||||||
}
|
|
||||||
case "filesystem":
|
|
||||||
if cfg.Storage.Filesystem.RootDir == "" {
|
|
||||||
logger.Fatal().Msg("storage.filesystem.root_dir is required when backend is \"filesystem\"")
|
|
||||||
}
|
|
||||||
s, fsErr := storage.NewFilesystemStore(cfg.Storage.Filesystem.RootDir)
|
s, fsErr := storage.NewFilesystemStore(cfg.Storage.Filesystem.RootDir)
|
||||||
if fsErr != nil {
|
if fsErr != nil {
|
||||||
logger.Fatal().Err(fsErr).Msg("failed to initialize filesystem storage")
|
logger.Fatal().Err(fsErr).Msg("failed to initialize filesystem storage")
|
||||||
}
|
}
|
||||||
store = s
|
store = s
|
||||||
logger.Info().Str("root", cfg.Storage.Filesystem.RootDir).Msg("connected to filesystem storage")
|
logger.Info().Str("root", cfg.Storage.Filesystem.RootDir).Msg("connected to filesystem storage")
|
||||||
default:
|
} else {
|
||||||
logger.Fatal().Str("backend", cfg.Storage.Backend).Msg("unknown storage backend")
|
logger.Info().Msg("storage not configured - file operations disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load schemas
|
// Load schemas
|
||||||
@@ -235,6 +212,19 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load approval workflow definitions (optional — directory may not exist yet)
|
||||||
|
var workflows map[string]*workflow.Workflow
|
||||||
|
if _, err := os.Stat(cfg.Workflows.Directory); err == nil {
|
||||||
|
workflows, err = workflow.LoadAll(cfg.Workflows.Directory)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal().Err(err).Str("directory", cfg.Workflows.Directory).Msg("failed to load workflow definitions")
|
||||||
|
}
|
||||||
|
logger.Info().Int("count", len(workflows)).Msg("loaded workflow definitions")
|
||||||
|
} else {
|
||||||
|
workflows = make(map[string]*workflow.Workflow)
|
||||||
|
logger.Info().Str("directory", cfg.Workflows.Directory).Msg("workflows directory not found, skipping")
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize module registry
|
// Initialize module registry
|
||||||
registry := modules.NewRegistry()
|
registry := modules.NewRegistry()
|
||||||
if err := modules.LoadState(registry, cfg, database.Pool()); err != nil {
|
if err := modules.LoadState(registry, cfg, database.Pool()); err != nil {
|
||||||
@@ -258,7 +248,7 @@ func main() {
|
|||||||
// Create API server
|
// Create API server
|
||||||
server := api.NewServer(logger, database, schemas, cfg.Schemas.Directory, store,
|
server := api.NewServer(logger, database, schemas, cfg.Schemas.Directory, store,
|
||||||
authService, sessionManager, oidcBackend, &cfg.Auth, broker, serverState,
|
authService, sessionManager, oidcBackend, &cfg.Auth, broker, serverState,
|
||||||
jobDefs, cfg.Jobs.Directory, registry, cfg)
|
jobDefs, cfg.Jobs.Directory, registry, cfg, workflows)
|
||||||
router := api.NewRouter(server, logger)
|
router := api.NewRouter(server, logger)
|
||||||
|
|
||||||
// Start background sweepers for job/runner timeouts (only when jobs module enabled)
|
// Start background sweepers for job/runner timeouts (only when jobs module enabled)
|
||||||
|
|||||||
@@ -17,17 +17,9 @@ database:
|
|||||||
max_connections: 10
|
max_connections: 10
|
||||||
|
|
||||||
storage:
|
storage:
|
||||||
backend: "minio" # "minio" (default) or "filesystem"
|
backend: "filesystem"
|
||||||
# MinIO/S3 settings (used when backend: "minio")
|
filesystem:
|
||||||
endpoint: "localhost:9000" # Use "minio:9000" for Docker Compose
|
root_dir: "/opt/silo/data" # Override with SILO_STORAGE_ROOT_DIR env var
|
||||||
access_key: "" # Use SILO_MINIO_ACCESS_KEY env var
|
|
||||||
secret_key: "" # Use SILO_MINIO_SECRET_KEY env var
|
|
||||||
bucket: "silo-files"
|
|
||||||
use_ssl: true # Use false for Docker Compose (internal network)
|
|
||||||
region: "us-east-1"
|
|
||||||
# Filesystem settings (used when backend: "filesystem")
|
|
||||||
# filesystem:
|
|
||||||
# root_dir: "/var/lib/silo/objects"
|
|
||||||
|
|
||||||
schemas:
|
schemas:
|
||||||
# Directory containing YAML schema files
|
# Directory containing YAML schema files
|
||||||
|
|||||||
@@ -17,12 +17,9 @@ database:
|
|||||||
max_connections: 10
|
max_connections: 10
|
||||||
|
|
||||||
storage:
|
storage:
|
||||||
endpoint: "minio:9000"
|
backend: "filesystem"
|
||||||
access_key: "${MINIO_ACCESS_KEY:-silominio}"
|
filesystem:
|
||||||
secret_key: "${MINIO_SECRET_KEY:-silominiosecret}"
|
root_dir: "/var/lib/silo/data"
|
||||||
bucket: "silo-files"
|
|
||||||
use_ssl: false
|
|
||||||
region: "us-east-1"
|
|
||||||
|
|
||||||
schemas:
|
schemas:
|
||||||
directory: "/etc/silo/schemas"
|
directory: "/etc/silo/schemas"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Silo All-in-One Stack
|
# Silo All-in-One Stack
|
||||||
# PostgreSQL + MinIO + OpenLDAP + Silo API + Nginx (optional)
|
# PostgreSQL + OpenLDAP + Silo API + Nginx (optional)
|
||||||
#
|
#
|
||||||
# Quick start:
|
# Quick start:
|
||||||
# ./scripts/setup-docker.sh
|
# ./scripts/setup-docker.sh
|
||||||
@@ -40,29 +40,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- silo-net
|
- silo-net
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# MinIO (S3-compatible object storage)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
minio:
|
|
||||||
image: minio/minio:latest
|
|
||||||
container_name: silo-minio
|
|
||||||
restart: unless-stopped
|
|
||||||
command: server /data --console-address ":9001"
|
|
||||||
environment:
|
|
||||||
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?Run ./scripts/setup-docker.sh first}
|
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?Run ./scripts/setup-docker.sh first}
|
|
||||||
volumes:
|
|
||||||
- minio_data:/data
|
|
||||||
ports:
|
|
||||||
- "9001:9001" # MinIO console (remove in hardened setups)
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
networks:
|
|
||||||
- silo-net
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# OpenLDAP (user directory for LDAP authentication)
|
# OpenLDAP (user directory for LDAP authentication)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -83,9 +60,13 @@ services:
|
|||||||
- openldap_data:/bitnami/openldap
|
- openldap_data:/bitnami/openldap
|
||||||
- ./ldap:/docker-entrypoint-initdb.d:ro
|
- ./ldap:/docker-entrypoint-initdb.d:ro
|
||||||
ports:
|
ports:
|
||||||
- "1389:1389" # LDAP access for debugging (remove in hardened setups)
|
- "1389:1389" # LDAP access for debugging (remove in hardened setups)
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost:1389 -b dc=silo,dc=local -D cn=admin,dc=silo,dc=local -w $${LDAP_ADMIN_PASSWORD} '(objectClass=organization)' >/dev/null 2>&1"]
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"ldapsearch -x -H ldap://localhost:1389 -b dc=silo,dc=local -D cn=admin,dc=silo,dc=local -w $${LDAP_ADMIN_PASSWORD} '(objectClass=organization)' >/dev/null 2>&1",
|
||||||
|
]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -104,8 +85,6 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio:
|
|
||||||
condition: service_healthy
|
|
||||||
openldap:
|
openldap:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
env_file:
|
env_file:
|
||||||
@@ -117,12 +96,10 @@ services:
|
|||||||
SILO_DB_NAME: silo
|
SILO_DB_NAME: silo
|
||||||
SILO_DB_USER: silo
|
SILO_DB_USER: silo
|
||||||
SILO_DB_PASSWORD: ${POSTGRES_PASSWORD}
|
SILO_DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
SILO_MINIO_ENDPOINT: minio:9000
|
|
||||||
SILO_MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
|
|
||||||
SILO_MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
|
|
||||||
ports:
|
ports:
|
||||||
- "${SILO_PORT:-8080}:8080"
|
- "${SILO_PORT:-8080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
|
- silo_data:/var/lib/silo/data
|
||||||
- ../schemas:/etc/silo/schemas:ro
|
- ../schemas:/etc/silo/schemas:ro
|
||||||
- ./config.docker.yaml:/etc/silo/config.yaml:ro
|
- ./config.docker.yaml:/etc/silo/config.yaml:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -164,7 +141,7 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
minio_data:
|
silo_data:
|
||||||
openldap_data:
|
openldap_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
# Production Docker Compose for Silo
|
# Production Docker Compose for Silo
|
||||||
# Uses external PostgreSQL (psql.example.internal) and MinIO (minio.example.internal)
|
# Uses external PostgreSQL (psql.example.internal) and filesystem storage
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# export SILO_DB_PASSWORD=<your-password>
|
# export SILO_DB_PASSWORD=<your-password>
|
||||||
# export SILO_MINIO_ACCESS_KEY=<your-access-key>
|
|
||||||
# export SILO_MINIO_SECRET_KEY=<your-secret-key>
|
|
||||||
# docker compose -f docker-compose.prod.yaml up -d
|
# docker compose -f docker-compose.prod.yaml up -d
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -24,14 +22,6 @@ services:
|
|||||||
# Note: SILO_DB_PORT and SILO_DB_SSLMODE are NOT supported as direct
|
# Note: SILO_DB_PORT and SILO_DB_SSLMODE are NOT supported as direct
|
||||||
# env var overrides. Set these in config.yaml instead, or use ${VAR}
|
# env var overrides. Set these in config.yaml instead, or use ${VAR}
|
||||||
# syntax in the YAML file. See docs/CONFIGURATION.md for details.
|
# syntax in the YAML file. See docs/CONFIGURATION.md for details.
|
||||||
|
|
||||||
# MinIO storage (minio.example.internal)
|
|
||||||
# Supported as direct env var overrides:
|
|
||||||
SILO_MINIO_ENDPOINT: minio.example.internal:9000
|
|
||||||
SILO_MINIO_ACCESS_KEY: ${SILO_MINIO_ACCESS_KEY:?MinIO access key required}
|
|
||||||
SILO_MINIO_SECRET_KEY: ${SILO_MINIO_SECRET_KEY:?MinIO secret key required}
|
|
||||||
# Note: SILO_MINIO_BUCKET and SILO_MINIO_USE_SSL are NOT supported as
|
|
||||||
# direct env var overrides. Set these in config.yaml instead.
|
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -19,26 +19,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- silo-network
|
- silo-network
|
||||||
|
|
||||||
minio:
|
|
||||||
image: minio/minio:RELEASE.2023-05-04T21-44-30Z
|
|
||||||
container_name: silo-minio
|
|
||||||
command: server /data --console-address ":9001"
|
|
||||||
environment:
|
|
||||||
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-silominio}
|
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-silominiosecret}
|
|
||||||
volumes:
|
|
||||||
- minio_data:/data
|
|
||||||
ports:
|
|
||||||
- "9000:9000"
|
|
||||||
- "9001:9001"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
networks:
|
|
||||||
- silo-network
|
|
||||||
|
|
||||||
silo:
|
silo:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
@@ -47,19 +27,12 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
environment:
|
||||||
SILO_DB_HOST: postgres
|
SILO_DB_HOST: postgres
|
||||||
SILO_DB_PORT: 5432
|
SILO_DB_PORT: 5432
|
||||||
SILO_DB_NAME: silo
|
SILO_DB_NAME: silo
|
||||||
SILO_DB_USER: silo
|
SILO_DB_USER: silo
|
||||||
SILO_DB_PASSWORD: ${POSTGRES_PASSWORD:-silodev}
|
SILO_DB_PASSWORD: ${POSTGRES_PASSWORD:-silodev}
|
||||||
SILO_MINIO_ENDPOINT: minio:9000
|
|
||||||
SILO_MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-silominio}
|
|
||||||
SILO_MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-silominiosecret}
|
|
||||||
SILO_MINIO_BUCKET: silo-files
|
|
||||||
SILO_MINIO_USE_SSL: "false"
|
|
||||||
SILO_SESSION_SECRET: ${SILO_SESSION_SECRET:-change-me-in-production}
|
SILO_SESSION_SECRET: ${SILO_SESSION_SECRET:-change-me-in-production}
|
||||||
SILO_OIDC_CLIENT_SECRET: ${SILO_OIDC_CLIENT_SECRET:-}
|
SILO_OIDC_CLIENT_SECRET: ${SILO_OIDC_CLIENT_SECRET:-}
|
||||||
SILO_LDAP_BIND_PASSWORD: ${SILO_LDAP_BIND_PASSWORD:-}
|
SILO_LDAP_BIND_PASSWORD: ${SILO_LDAP_BIND_PASSWORD:-}
|
||||||
@@ -68,6 +41,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
|
- silo_data:/var/lib/silo/data
|
||||||
- ../schemas:/etc/silo/schemas:ro
|
- ../schemas:/etc/silo/schemas:ro
|
||||||
- ./config.dev.yaml:/etc/silo/config.yaml:ro
|
- ./config.dev.yaml:/etc/silo/config.yaml:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -80,7 +54,7 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
minio_data:
|
silo_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
silo-network:
|
silo-network:
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ NoNewPrivileges=yes
|
|||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=yes
|
ProtectHome=yes
|
||||||
PrivateTmp=yes
|
PrivateTmp=yes
|
||||||
|
ReadWritePaths=/opt/silo/data
|
||||||
ReadOnlyPaths=/etc/silo /opt/silo
|
ReadOnlyPaths=/etc/silo /opt/silo
|
||||||
|
|
||||||
# Resource limits
|
# Resource limits
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Configuration Reference
|
# Configuration Reference
|
||||||
|
|
||||||
**Last Updated:** 2026-02-06
|
**Last Updated:** 2026-03-01
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -153,6 +153,70 @@ odoo:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Approval Workflows
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|-----|------|---------|-------------|
|
||||||
|
| `workflows.directory` | string | `"/etc/silo/workflows"` | Path to directory containing YAML workflow definition files |
|
||||||
|
|
||||||
|
Workflow definition files describe multi-stage approval processes using a state machine pattern. Each file defines a workflow with states, transitions, and approver requirements.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
workflows:
|
||||||
|
directory: "/etc/silo/workflows"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Solver
|
||||||
|
|
||||||
|
| Key | Type | Default | Env Override | Description |
|
||||||
|
|-----|------|---------|-------------|-------------|
|
||||||
|
| `solver.default_solver` | string | `""` | `SILO_SOLVER_DEFAULT` | Default solver backend name |
|
||||||
|
| `solver.max_context_size_mb` | int | `10` | — | Maximum SolveContext payload size in MB |
|
||||||
|
| `solver.default_timeout` | int | `300` | — | Default solver job timeout in seconds |
|
||||||
|
| `solver.auto_diagnose_on_commit` | bool | `false` | — | Auto-submit diagnose job on assembly revision commit |
|
||||||
|
|
||||||
|
The solver module depends on the `jobs` module being enabled. See [SOLVER.md](SOLVER.md) for the full solver service specification.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
solver:
|
||||||
|
default_solver: "ondsel"
|
||||||
|
max_context_size_mb: 10
|
||||||
|
default_timeout: 300
|
||||||
|
auto_diagnose_on_commit: true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
Optional module toggles. Each module can be explicitly enabled or disabled. If not listed, the module's built-in default applies. See [MODULES.md](MODULES.md) for the full module system specification.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
modules:
|
||||||
|
projects:
|
||||||
|
enabled: true
|
||||||
|
audit:
|
||||||
|
enabled: true
|
||||||
|
odoo:
|
||||||
|
enabled: false
|
||||||
|
freecad:
|
||||||
|
enabled: true
|
||||||
|
jobs:
|
||||||
|
enabled: false
|
||||||
|
dag:
|
||||||
|
enabled: false
|
||||||
|
solver:
|
||||||
|
enabled: false
|
||||||
|
sessions:
|
||||||
|
enabled: true
|
||||||
|
```
|
||||||
|
|
||||||
|
The `auth.enabled` field controls the `auth` module directly (not duplicated under `modules:`). The `sessions` module depends on `auth` and is enabled by default.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Authentication has a master toggle and three independent backends. When `auth.enabled` is `false`, all routes are accessible without login and a synthetic admin user (`dev`) is injected into every request.
|
Authentication has a master toggle and three independent backends. When `auth.enabled` is `false`, all routes are accessible without login and a synthetic admin user (`dev`) is injected into every request.
|
||||||
@@ -271,6 +335,7 @@ All environment variable overrides. These take precedence over values in `config
|
|||||||
| `SILO_ADMIN_PASSWORD` | `auth.local.default_admin_password` | Default admin password |
|
| `SILO_ADMIN_PASSWORD` | `auth.local.default_admin_password` | Default admin password |
|
||||||
| `SILO_LDAP_BIND_PASSWORD` | `auth.ldap.bind_password` | LDAP service account password |
|
| `SILO_LDAP_BIND_PASSWORD` | `auth.ldap.bind_password` | LDAP service account password |
|
||||||
| `SILO_OIDC_CLIENT_SECRET` | `auth.oidc.client_secret` | OIDC client secret |
|
| `SILO_OIDC_CLIENT_SECRET` | `auth.oidc.client_secret` | OIDC client secret |
|
||||||
|
| `SILO_SOLVER_DEFAULT` | `solver.default_solver` | Default solver backend name |
|
||||||
|
|
||||||
Additionally, YAML values can reference environment variables directly using `${VAR_NAME}` syntax, which is expanded at load time via `os.ExpandEnv()`.
|
Additionally, YAML values can reference environment variables directly using `${VAR_NAME}` syntax, which is expanded at load time via `os.ExpandEnv()`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Silo Gap Analysis
|
# Silo Gap Analysis
|
||||||
|
|
||||||
**Date:** 2026-02-13
|
**Date:** 2026-03-01
|
||||||
**Status:** Analysis Complete (Updated)
|
**Status:** Analysis Complete (Updated)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -130,8 +130,8 @@ FreeCAD workbench maintained in separate [silo-mod](https://git.kindred-systems.
|
|||||||
|-----|-------------|--------|--------|
|
|-----|-------------|--------|--------|
|
||||||
| ~~**No rollback**~~ | ~~Cannot revert to previous revision~~ | ~~Data recovery difficult~~ | **Implemented** |
|
| ~~**No rollback**~~ | ~~Cannot revert to previous revision~~ | ~~Data recovery difficult~~ | **Implemented** |
|
||||||
| ~~**No comparison**~~ | ~~Cannot diff between revisions~~ | ~~Change tracking manual~~ | **Implemented** |
|
| ~~**No comparison**~~ | ~~Cannot diff between revisions~~ | ~~Change tracking manual~~ | **Implemented** |
|
||||||
| **No locking** | No concurrent edit protection | Multi-user unsafe | Open |
|
| **No locking** | No concurrent edit protection | Multi-user unsafe | Partial (edit sessions with hard interference detection; full pessimistic locking not yet implemented) |
|
||||||
| **No approval workflow** | No release/sign-off process | Quality control gap | Open |
|
| ~~**No approval workflow**~~ | ~~No release/sign-off process~~ | ~~Quality control gap~~ | **Implemented** (YAML-configurable ECO workflows, multi-stage review gates, digital signatures) |
|
||||||
|
|
||||||
### 3.2 Important Gaps
|
### 3.2 Important Gaps
|
||||||
|
|
||||||
@@ -355,47 +355,54 @@ These design decisions remain unresolved:
|
|||||||
|
|
||||||
## Appendix A: File Structure
|
## Appendix A: File Structure
|
||||||
|
|
||||||
Revision endpoints, status, labels, authentication, audit logging, and file attachments are implemented. Current structure:
|
Current structure:
|
||||||
|
|
||||||
```
|
```
|
||||||
internal/
|
internal/
|
||||||
api/
|
api/
|
||||||
|
approval_handlers.go # Approval/ECO workflow endpoints
|
||||||
audit_handlers.go # Audit/completeness endpoints
|
audit_handlers.go # Audit/completeness endpoints
|
||||||
auth_handlers.go # Login, tokens, OIDC
|
auth_handlers.go # Login, tokens, OIDC
|
||||||
bom_handlers.go # Flat BOM, cost roll-up
|
bom_handlers.go # Flat BOM, cost roll-up
|
||||||
|
broker.go # SSE broker with targeted delivery
|
||||||
|
dag_handlers.go # Dependency DAG endpoints
|
||||||
|
dependency_handlers.go # .kc dependency resolution
|
||||||
file_handlers.go # Presigned uploads, item files, thumbnails
|
file_handlers.go # Presigned uploads, item files, thumbnails
|
||||||
handlers.go # Items, schemas, projects, revisions
|
handlers.go # Items, schemas, projects, revisions, Server struct
|
||||||
|
job_handlers.go # Job queue endpoints
|
||||||
|
location_handlers.go # Location hierarchy endpoints
|
||||||
|
macro_handlers.go # .kc macro endpoints
|
||||||
|
metadata_handlers.go # .kc metadata endpoints
|
||||||
middleware.go # Auth middleware
|
middleware.go # Auth middleware
|
||||||
odoo_handlers.go # Odoo integration endpoints
|
odoo_handlers.go # Odoo integration endpoints
|
||||||
routes.go # Route registration (78 endpoints)
|
pack_handlers.go # .kc checkout packing
|
||||||
|
routes.go # Route registration (~140 endpoints)
|
||||||
|
runner_handlers.go # Job runner endpoints
|
||||||
search.go # Fuzzy search
|
search.go # Fuzzy search
|
||||||
|
session_handlers.go # Edit session acquire/release/query
|
||||||
|
settings_handlers.go # Admin settings endpoints
|
||||||
|
solver_handlers.go # Solver service endpoints
|
||||||
|
sse_handler.go # SSE event stream handler
|
||||||
|
workstation_handlers.go # Workstation registration
|
||||||
auth/
|
auth/
|
||||||
auth.go # Auth service: local, LDAP, OIDC
|
auth.go # Auth service: local, LDAP, OIDC
|
||||||
db/
|
db/
|
||||||
|
edit_sessions.go # Edit session repository
|
||||||
items.go # Item and revision repository
|
items.go # Item and revision repository
|
||||||
item_files.go # File attachment repository
|
item_files.go # File attachment repository
|
||||||
relationships.go # BOM repository
|
jobs.go # Job queue repository
|
||||||
projects.go # Project repository
|
projects.go # Project repository
|
||||||
|
relationships.go # BOM repository
|
||||||
|
workstations.go # Workstation repository
|
||||||
|
modules/
|
||||||
|
modules.go # Module registry (12 modules)
|
||||||
|
loader.go # Config-to-module state loader
|
||||||
storage/
|
storage/
|
||||||
storage.go # File storage helpers
|
storage.go # File storage helpers
|
||||||
migrations/
|
migrations/
|
||||||
001_initial.sql # Core schema
|
001_initial.sql # Core schema
|
||||||
...
|
...
|
||||||
011_item_files.sql # Item file attachments (latest)
|
023_edit_sessions.sql # Edit session tracking (latest)
|
||||||
```
|
|
||||||
|
|
||||||
Future features would add:
|
|
||||||
|
|
||||||
```
|
|
||||||
internal/
|
|
||||||
api/
|
|
||||||
lock_handlers.go # Locking endpoints
|
|
||||||
db/
|
|
||||||
locks.go # Lock repository
|
|
||||||
releases.go # Release repository
|
|
||||||
migrations/
|
|
||||||
012_item_locks.sql # Locking table
|
|
||||||
013_releases.sql # Release management
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -465,28 +472,28 @@ This section compares Silo's capabilities against SOLIDWORKS PDM features. Gaps
|
|||||||
|
|
||||||
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
||||||
|---------|---------------|-------------|----------|------------|
|
|---------|---------------|-------------|----------|------------|
|
||||||
| Check-in/check-out | Full pessimistic locking | None | High | Moderate |
|
| Check-in/check-out | Full pessimistic locking | Partial (edit sessions with hard interference) | High | Moderate |
|
||||||
| Version history | Complete with branching | Full (linear) | - | - |
|
| Version history | Complete with branching | Full (linear) | - | - |
|
||||||
| Revision labels | A, B, C or custom schemes | Full (custom labels) | - | - |
|
| Revision labels | A, B, C or custom schemes | Full (custom labels) | - | - |
|
||||||
| Rollback/restore | Full | Full | - | - |
|
| Rollback/restore | Full | Full | - | - |
|
||||||
| Compare revisions | Visual + metadata diff | Metadata diff only | Medium | Complex |
|
| Compare revisions | Visual + metadata diff | Metadata diff only | Medium | Complex |
|
||||||
| Get Latest Revision | One-click retrieval | Partial (API only) | Medium | Simple |
|
| Get Latest Revision | One-click retrieval | Partial (API only) | Medium | Simple |
|
||||||
|
|
||||||
Silo lacks pessimistic locking (check-out), which is critical for multi-user CAD environments where file merging is impractical. Visual diff comparison would require FreeCAD integration for CAD file visualization.
|
Silo has edit sessions with hard interference detection (unique index on item + context_level + object_id prevents two users from editing the same object simultaneously). Full pessimistic file-level locking is not yet implemented. Visual diff comparison would require FreeCAD integration for CAD file visualization.
|
||||||
|
|
||||||
### C.2 Workflow Management
|
### C.2 Workflow Management
|
||||||
|
|
||||||
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
||||||
|---------|---------------|-------------|----------|------------|
|
|---------|---------------|-------------|----------|------------|
|
||||||
| Custom workflows | Full visual designer | None | Critical | Complex |
|
| Custom workflows | Full visual designer | Full (YAML-defined state machines) | - | - |
|
||||||
| State transitions | Configurable with permissions | Basic (status field only) | Critical | Complex |
|
| State transitions | Configurable with permissions | Full (configurable transition rules) | - | - |
|
||||||
| Parallel approvals | Multiple approvers required | None | High | Complex |
|
| Parallel approvals | Multiple approvers required | Full (multi-stage review gates) | - | - |
|
||||||
| Automatic transitions | Timer/condition-based | None | Medium | Moderate |
|
| Automatic transitions | Timer/condition-based | None | Medium | Moderate |
|
||||||
| Email notifications | On state change | None | High | Moderate |
|
| Email notifications | On state change | None | High | Moderate |
|
||||||
| ECO process | Built-in change management | None | High | Complex |
|
| ECO process | Built-in change management | Full (YAML-configurable ECO workflows) | - | - |
|
||||||
| Child state conditions | Block parent if children invalid | None | Medium | Moderate |
|
| Child state conditions | Block parent if children invalid | None | Medium | Moderate |
|
||||||
|
|
||||||
Workflow management is the largest functional gap. SOLIDWORKS PDM offers sophisticated state machines with parallel approvals, automatic transitions, and deep integration with engineering change processes. Silo currently has only a simple status field (draft/review/released/obsolete) with no transition rules or approval processes.
|
Workflow management has been significantly addressed. Silo now supports YAML-defined state machine workflows with configurable transitions, multi-stage approval gates, and digital signatures. Remaining gaps: automatic timer-based transitions, email notifications, and child state condition enforcement.
|
||||||
|
|
||||||
### C.3 User Management & Security
|
### C.3 User Management & Security
|
||||||
|
|
||||||
@@ -549,13 +556,13 @@ CAD integration is maintained in separate repositories ([silo-mod](https://git.k
|
|||||||
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
||||||
|---------|---------------|-------------|----------|------------|
|
|---------|---------------|-------------|----------|------------|
|
||||||
| ERP integration | SAP, Dynamics, etc. | Partial (Odoo stubs) | Medium | Complex |
|
| ERP integration | SAP, Dynamics, etc. | Partial (Odoo stubs) | Medium | Complex |
|
||||||
| API access | Full COM/REST API | Full REST API (78 endpoints) | - | - |
|
| API access | Full COM/REST API | Full REST API (~140 endpoints) | - | - |
|
||||||
| Dispatch scripts | Automation without coding | None | Medium | Moderate |
|
| Dispatch scripts | Automation without coding | None | Medium | Moderate |
|
||||||
| Task scheduler | Background processing | None | Medium | Moderate |
|
| Task scheduler | Background processing | Full (job queue with runners) | - | - |
|
||||||
| Email system | SMTP integration | None | High | Simple |
|
| Email system | SMTP integration | None | High | Simple |
|
||||||
| Web portal | Browser access | Full (React SPA + auth) | - | - |
|
| Web portal | Browser access | Full (React SPA + auth) | - | - |
|
||||||
|
|
||||||
Silo has a comprehensive REST API (78 endpoints) and a full web UI with authentication. Odoo ERP integration has config/sync-log scaffolding but push/pull operations are stubs. Remaining gaps: email notifications, task scheduler, dispatch automation.
|
Silo has a comprehensive REST API (~140 endpoints) and a full web UI with authentication. Odoo ERP integration has config/sync-log scaffolding but push/pull operations are stubs. Job queue with runner management is fully implemented. Remaining gaps: email notifications, dispatch automation.
|
||||||
|
|
||||||
### C.8 Reporting & Analytics
|
### C.8 Reporting & Analytics
|
||||||
|
|
||||||
@@ -586,13 +593,13 @@ File storage works well. Thumbnail generation and file preview would significant
|
|||||||
|
|
||||||
| Category | Feature | SW PDM Standard | SW PDM Pro | Silo Current | Silo Planned |
|
| Category | Feature | SW PDM Standard | SW PDM Pro | Silo Current | Silo Planned |
|
||||||
|----------|---------|-----------------|------------|--------------|--------------|
|
|----------|---------|-----------------|------------|--------------|--------------|
|
||||||
| **Version Control** | Check-in/out | Yes | Yes | No | Tier 1 |
|
| **Version Control** | Check-in/out | Yes | Yes | Partial (edit sessions) | Tier 1 |
|
||||||
| | Version history | Yes | Yes | Yes | - |
|
| | Version history | Yes | Yes | Yes | - |
|
||||||
| | Rollback | Yes | Yes | Yes | - |
|
| | Rollback | Yes | Yes | Yes | - |
|
||||||
| | Revision labels/status | Yes | Yes | Yes | - |
|
| | Revision labels/status | Yes | Yes | Yes | - |
|
||||||
| | Revision comparison | Yes | Yes | Yes (metadata) | - |
|
| | Revision comparison | Yes | Yes | Yes (metadata) | - |
|
||||||
| **Workflow** | Custom workflows | Limited | Yes | No | Tier 4 |
|
| **Workflow** | Custom workflows | Limited | Yes | Yes (YAML state machines) | - |
|
||||||
| | Parallel approval | No | Yes | No | Tier 4 |
|
| | Parallel approval | No | Yes | Yes (multi-stage gates) | - |
|
||||||
| | Notifications | No | Yes | No | Tier 1 |
|
| | Notifications | No | Yes | No | Tier 1 |
|
||||||
| **Security** | User auth | Windows | Windows/LDAP | Yes (local, LDAP, OIDC) | - |
|
| **Security** | User auth | Windows | Windows/LDAP | Yes (local, LDAP, OIDC) | - |
|
||||||
| | Permissions | Basic | Granular | Partial (role-based) | Tier 4 |
|
| | Permissions | Basic | Granular | Partial (role-based) | Tier 4 |
|
||||||
@@ -606,7 +613,7 @@ File storage works well. Thumbnail generation and file preview would significant
|
|||||||
| **Data** | CSV import/export | Yes | Yes | Yes | - |
|
| **Data** | CSV import/export | Yes | Yes | Yes | - |
|
||||||
| | ODS import/export | No | No | Yes | - |
|
| | ODS import/export | No | No | Yes | - |
|
||||||
| | Project management | Yes | Yes | Yes | - |
|
| | Project management | Yes | Yes | Yes | - |
|
||||||
| **Integration** | API | Limited | Full | Full REST (78) | - |
|
| **Integration** | API | Limited | Full | Full REST (~140) | - |
|
||||||
| | ERP connectors | No | Yes | Partial (Odoo stubs) | Tier 6 |
|
| | ERP connectors | No | Yes | Partial (Odoo stubs) | Tier 6 |
|
||||||
| | Web access | No | Yes | Yes (React SPA + auth) | - |
|
| | Web access | No | Yes | Yes (React SPA + auth) | - |
|
||||||
| **Files** | Versioning | Yes | Yes | Yes | - |
|
| **Files** | Versioning | Yes | Yes | Yes | - |
|
||||||
|
|||||||
@@ -491,4 +491,7 @@ After a successful installation:
|
|||||||
| [SPECIFICATION.md](SPECIFICATION.md) | Full design specification and API reference |
|
| [SPECIFICATION.md](SPECIFICATION.md) | Full design specification and API reference |
|
||||||
| [STATUS.md](STATUS.md) | Implementation status |
|
| [STATUS.md](STATUS.md) | Implementation status |
|
||||||
| [GAP_ANALYSIS.md](GAP_ANALYSIS.md) | Gap analysis and revision control roadmap |
|
| [GAP_ANALYSIS.md](GAP_ANALYSIS.md) | Gap analysis and revision control roadmap |
|
||||||
|
| [MODULES.md](MODULES.md) | Module system specification |
|
||||||
|
| [WORKERS.md](WORKERS.md) | Job queue and runner system |
|
||||||
|
| [SOLVER.md](SOLVER.md) | Assembly solver service |
|
||||||
| [COMPONENT_AUDIT.md](COMPONENT_AUDIT.md) | Component audit tool design |
|
| [COMPONENT_AUDIT.md](COMPONENT_AUDIT.md) | Component audit tool design |
|
||||||
|
|||||||
797
docs/MODULES.md
Normal file
797
docs/MODULES.md
Normal file
@@ -0,0 +1,797 @@
|
|||||||
|
# Module System Specification
|
||||||
|
|
||||||
|
**Status:** Draft
|
||||||
|
**Last Updated:** 2026-03-01
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
Silo's module system defines the boundary between required infrastructure and optional capabilities. Each module groups a set of API endpoints, UI views, and configuration parameters. Modules can be enabled or disabled at runtime by administrators via the web UI, and clients can query which modules are active to adapt their feature set.
|
||||||
|
|
||||||
|
The goal: after initial deployment (where `config.yaml` sets database, storage, and server bind), all further operational configuration happens through the admin settings UI. The YAML file becomes the bootstrap; the database becomes the runtime source of truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Module Registry
|
||||||
|
|
||||||
|
### 2.1 Required Modules
|
||||||
|
|
||||||
|
These cannot be disabled. They define what Silo *is*.
|
||||||
|
|
||||||
|
| Module ID | Name | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `core` | Core PDM | Items, revisions, files, BOM, search, import/export, part number generation |
|
||||||
|
| `schemas` | Schemas | Part numbering schema parsing, segment management, form descriptors |
|
||||||
|
| `storage` | Storage | Filesystem storage |
|
||||||
|
|
||||||
|
### 2.2 Optional Modules
|
||||||
|
|
||||||
|
| Module ID | Name | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `auth` | Authentication | `true` | Local, LDAP, OIDC authentication and RBAC |
|
||||||
|
| `projects` | Projects | `true` | Project management and item tagging |
|
||||||
|
| `audit` | Audit | `true` | Audit logging, completeness scoring |
|
||||||
|
| `odoo` | Odoo ERP | `false` | Odoo integration (config, sync-log, push/pull) |
|
||||||
|
| `freecad` | Create Integration | `true` | URI scheme, executable path, client settings |
|
||||||
|
| `jobs` | Job Queue | `false` | Async compute jobs, runner management |
|
||||||
|
| `dag` | Dependency DAG | `false` | Feature DAG sync, validation states, interference detection |
|
||||||
|
| `solver` | Solver | `false` | Assembly constraint solving via server-side runners |
|
||||||
|
| `sessions` | Sessions | `true` | Workstation registration, edit sessions, and presence tracking |
|
||||||
|
|
||||||
|
### 2.3 Module Dependencies
|
||||||
|
|
||||||
|
Some modules require others to function:
|
||||||
|
|
||||||
|
| Module | Requires |
|
||||||
|
|--------|----------|
|
||||||
|
| `dag` | `jobs` |
|
||||||
|
| `jobs` | `auth` (runner tokens) |
|
||||||
|
| `odoo` | `auth` |
|
||||||
|
| `solver` | `jobs` |
|
||||||
|
| `sessions` | `auth` |
|
||||||
|
|
||||||
|
When enabling a module, its dependencies are validated. The server rejects enabling `dag` without `jobs`. Disabling a module that others depend on shows a warning listing dependents.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Endpoint-to-Module Mapping
|
||||||
|
|
||||||
|
### 3.1 `core` (required)
|
||||||
|
|
||||||
|
```
|
||||||
|
# Health
|
||||||
|
GET /health
|
||||||
|
GET /ready
|
||||||
|
|
||||||
|
# Items
|
||||||
|
GET /api/items
|
||||||
|
GET /api/items/search
|
||||||
|
GET /api/items/by-uuid/{uuid}
|
||||||
|
GET /api/items/export.csv
|
||||||
|
GET /api/items/template.csv
|
||||||
|
GET /api/items/export.ods
|
||||||
|
GET /api/items/template.ods
|
||||||
|
POST /api/items
|
||||||
|
POST /api/items/import
|
||||||
|
POST /api/items/import.ods
|
||||||
|
GET /api/items/{partNumber}
|
||||||
|
PUT /api/items/{partNumber}
|
||||||
|
DELETE /api/items/{partNumber}
|
||||||
|
|
||||||
|
# Revisions
|
||||||
|
GET /api/items/{partNumber}/revisions
|
||||||
|
GET /api/items/{partNumber}/revisions/compare
|
||||||
|
GET /api/items/{partNumber}/revisions/{revision}
|
||||||
|
POST /api/items/{partNumber}/revisions
|
||||||
|
PATCH /api/items/{partNumber}/revisions/{revision}
|
||||||
|
POST /api/items/{partNumber}/revisions/{revision}/rollback
|
||||||
|
|
||||||
|
# Files
|
||||||
|
GET /api/items/{partNumber}/files
|
||||||
|
GET /api/items/{partNumber}/file
|
||||||
|
GET /api/items/{partNumber}/file/{revision}
|
||||||
|
POST /api/items/{partNumber}/file
|
||||||
|
POST /api/items/{partNumber}/files
|
||||||
|
DELETE /api/items/{partNumber}/files/{fileId}
|
||||||
|
PUT /api/items/{partNumber}/thumbnail
|
||||||
|
POST /api/uploads/presign
|
||||||
|
|
||||||
|
# BOM
|
||||||
|
GET /api/items/{partNumber}/bom
|
||||||
|
GET /api/items/{partNumber}/bom/expanded
|
||||||
|
GET /api/items/{partNumber}/bom/flat
|
||||||
|
GET /api/items/{partNumber}/bom/cost
|
||||||
|
GET /api/items/{partNumber}/bom/where-used
|
||||||
|
GET /api/items/{partNumber}/bom/export.csv
|
||||||
|
GET /api/items/{partNumber}/bom/export.ods
|
||||||
|
POST /api/items/{partNumber}/bom
|
||||||
|
POST /api/items/{partNumber}/bom/import
|
||||||
|
POST /api/items/{partNumber}/bom/merge
|
||||||
|
PUT /api/items/{partNumber}/bom/{childPartNumber}
|
||||||
|
DELETE /api/items/{partNumber}/bom/{childPartNumber}
|
||||||
|
|
||||||
|
# .kc Metadata
|
||||||
|
GET /api/items/{partNumber}/metadata
|
||||||
|
PUT /api/items/{partNumber}/metadata
|
||||||
|
PATCH /api/items/{partNumber}/metadata/lifecycle
|
||||||
|
PATCH /api/items/{partNumber}/metadata/tags
|
||||||
|
|
||||||
|
# .kc Dependencies
|
||||||
|
GET /api/items/{partNumber}/dependencies
|
||||||
|
GET /api/items/{partNumber}/dependencies/resolve
|
||||||
|
|
||||||
|
# .kc Macros
|
||||||
|
GET /api/items/{partNumber}/macros
|
||||||
|
GET /api/items/{partNumber}/macros/{filename}
|
||||||
|
|
||||||
|
# Part Number Generation
|
||||||
|
POST /api/generate-part-number
|
||||||
|
|
||||||
|
# Sheets
|
||||||
|
POST /api/sheets/diff
|
||||||
|
|
||||||
|
# Settings & Modules (admin)
|
||||||
|
GET /api/modules
|
||||||
|
GET /api/admin/settings
|
||||||
|
GET /api/admin/settings/{module}
|
||||||
|
PUT /api/admin/settings/{module}
|
||||||
|
POST /api/admin/settings/{module}/test
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 `schemas` (required)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/schemas
|
||||||
|
GET /api/schemas/{name}
|
||||||
|
GET /api/schemas/{name}/form
|
||||||
|
POST /api/schemas/{name}/segments/{segment}/values
|
||||||
|
PUT /api/schemas/{name}/segments/{segment}/values/{code}
|
||||||
|
DELETE /api/schemas/{name}/segments/{segment}/values/{code}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 `storage` (required)
|
||||||
|
|
||||||
|
No dedicated endpoints — storage is consumed internally by file upload/download in `core`. Exposed through admin settings for connection status visibility.
|
||||||
|
|
||||||
|
### 3.4 `auth`
|
||||||
|
|
||||||
|
```
|
||||||
|
# Public (login flow)
|
||||||
|
GET /login
|
||||||
|
POST /login
|
||||||
|
POST /logout
|
||||||
|
GET /auth/oidc
|
||||||
|
GET /auth/callback
|
||||||
|
|
||||||
|
# Authenticated
|
||||||
|
GET /api/auth/me
|
||||||
|
GET /api/auth/tokens
|
||||||
|
POST /api/auth/tokens
|
||||||
|
DELETE /api/auth/tokens/{id}
|
||||||
|
|
||||||
|
# Web UI
|
||||||
|
GET /settings (account info, tokens)
|
||||||
|
POST /settings/tokens
|
||||||
|
POST /settings/tokens/{id}/revoke
|
||||||
|
```
|
||||||
|
|
||||||
|
When `auth` is disabled, all routes are open and a synthetic `dev` admin user is injected (current behavior).
|
||||||
|
|
||||||
|
### 3.5 `projects`
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/projects
|
||||||
|
GET /api/projects/{code}
|
||||||
|
GET /api/projects/{code}/items
|
||||||
|
GET /api/projects/{code}/sheet.ods
|
||||||
|
POST /api/projects
|
||||||
|
PUT /api/projects/{code}
|
||||||
|
DELETE /api/projects/{code}
|
||||||
|
|
||||||
|
# Item-project tagging
|
||||||
|
GET /api/items/{partNumber}/projects
|
||||||
|
POST /api/items/{partNumber}/projects
|
||||||
|
DELETE /api/items/{partNumber}/projects/{code}
|
||||||
|
```
|
||||||
|
|
||||||
|
When disabled: project tag endpoints return `404`, project columns are hidden in UI list views, project filter is removed from item search.
|
||||||
|
|
||||||
|
### 3.6 `audit`
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/audit/completeness
|
||||||
|
GET /api/audit/completeness/{partNumber}
|
||||||
|
```
|
||||||
|
|
||||||
|
When disabled: audit log table continues to receive writes (it's part of core middleware), but the completeness scoring endpoints and the Audit page in the web UI are hidden. Future: retention policies, export, and compliance reporting endpoints live here.
|
||||||
|
|
||||||
|
### 3.7 `odoo`
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/integrations/odoo/config
|
||||||
|
GET /api/integrations/odoo/sync-log
|
||||||
|
PUT /api/integrations/odoo/config
|
||||||
|
POST /api/integrations/odoo/test-connection
|
||||||
|
POST /api/integrations/odoo/sync/push/{partNumber}
|
||||||
|
POST /api/integrations/odoo/sync/pull/{odooId}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.8 `freecad`
|
||||||
|
|
||||||
|
No dedicated API endpoints currently. Configures URI scheme and executable path used by the web UI's "Open in Create" links and by CLI operations. Future: client configuration distribution endpoint.
|
||||||
|
|
||||||
|
### 3.9 `jobs`
|
||||||
|
|
||||||
|
```
|
||||||
|
# User-facing
|
||||||
|
GET /api/jobs
|
||||||
|
GET /api/jobs/{jobID}
|
||||||
|
GET /api/jobs/{jobID}/logs
|
||||||
|
POST /api/jobs
|
||||||
|
POST /api/jobs/{jobID}/cancel
|
||||||
|
|
||||||
|
# Job definitions
|
||||||
|
GET /api/job-definitions
|
||||||
|
GET /api/job-definitions/{name}
|
||||||
|
POST /api/job-definitions/reload
|
||||||
|
|
||||||
|
# Runner management (admin)
|
||||||
|
GET /api/runners
|
||||||
|
POST /api/runners
|
||||||
|
DELETE /api/runners/{runnerID}
|
||||||
|
|
||||||
|
# Runner-facing (runner token auth)
|
||||||
|
POST /api/runner/heartbeat
|
||||||
|
POST /api/runner/claim
|
||||||
|
PUT /api/runner/jobs/{jobID}/progress
|
||||||
|
POST /api/runner/jobs/{jobID}/complete
|
||||||
|
POST /api/runner/jobs/{jobID}/fail
|
||||||
|
POST /api/runner/jobs/{jobID}/log
|
||||||
|
PUT /api/runner/jobs/{jobID}/dag
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.10 `dag`
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/items/{partNumber}/dag
|
||||||
|
GET /api/items/{partNumber}/dag/forward-cone/{nodeKey}
|
||||||
|
GET /api/items/{partNumber}/dag/dirty
|
||||||
|
PUT /api/items/{partNumber}/dag
|
||||||
|
POST /api/items/{partNumber}/dag/mark-dirty/{nodeKey}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.11 `solver`
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/solver/jobs
|
||||||
|
GET /api/solver/jobs/{jobID}
|
||||||
|
POST /api/solver/jobs
|
||||||
|
POST /api/solver/jobs/{jobID}/cancel
|
||||||
|
GET /api/solver/solvers
|
||||||
|
GET /api/solver/results/{partNumber}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.12 `sessions`
|
||||||
|
|
||||||
|
```
|
||||||
|
# Workstation management
|
||||||
|
GET /api/workstations
|
||||||
|
POST /api/workstations
|
||||||
|
DELETE /api/workstations/{workstationID}
|
||||||
|
|
||||||
|
# Edit sessions (user-scoped)
|
||||||
|
GET /api/edit-sessions
|
||||||
|
|
||||||
|
# Edit sessions (item-scoped)
|
||||||
|
GET /api/items/{partNumber}/edit-sessions
|
||||||
|
POST /api/items/{partNumber}/edit-sessions
|
||||||
|
DELETE /api/items/{partNumber}/edit-sessions/{sessionID}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Disabled Module Behavior
|
||||||
|
|
||||||
|
When a module is disabled:
|
||||||
|
|
||||||
|
1. **API routes** registered by that module return `404 Not Found` with body `{"error": "module '<id>' is not enabled"}`.
|
||||||
|
2. **Web UI** hides the module's navigation entry, page, and any inline UI elements (e.g., project tags on item cards).
|
||||||
|
3. **SSE events** from the module are not broadcast.
|
||||||
|
4. **Background goroutines** (e.g., job timeout sweeper, runner heartbeat checker) are not started.
|
||||||
|
5. **Database tables** are not dropped — they remain for re-enablement. No data loss on disable/enable cycle.
|
||||||
|
|
||||||
|
Implementation: each module's route group is wrapped in a middleware check:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func RequireModule(id string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !modules.IsEnabled(id) {
|
||||||
|
http.Error(w, `{"error":"module '`+id+`' is not enabled"}`, 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Configuration Persistence
|
||||||
|
|
||||||
|
### 5.1 Precedence
|
||||||
|
|
||||||
|
```
|
||||||
|
Environment variables (highest — always wins, secrets live here)
|
||||||
|
↓
|
||||||
|
Database overrides (admin UI writes here)
|
||||||
|
↓
|
||||||
|
config.yaml (lowest — bootstrap defaults)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Database Table
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Migration 014_settings.sql
|
||||||
|
CREATE TABLE settings_overrides (
|
||||||
|
key TEXT PRIMARY KEY, -- dotted path: "auth.ldap.enabled"
|
||||||
|
value JSONB NOT NULL, -- typed value
|
||||||
|
updated_by TEXT NOT NULL, -- username
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE module_state (
|
||||||
|
module_id TEXT PRIMARY KEY, -- "auth", "projects", etc.
|
||||||
|
enabled BOOLEAN NOT NULL,
|
||||||
|
updated_by TEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Load Sequence
|
||||||
|
|
||||||
|
On startup:
|
||||||
|
|
||||||
|
1. Parse `config.yaml` into Go config struct.
|
||||||
|
2. Query `settings_overrides` — merge each key into the struct using dotted path resolution.
|
||||||
|
3. Apply environment variable overrides (existing `SILO_*` vars).
|
||||||
|
4. Query `module_state` — override default enabled/disabled from YAML.
|
||||||
|
5. Validate module dependencies.
|
||||||
|
6. Register only enabled modules' route groups.
|
||||||
|
7. Start only enabled modules' background goroutines.
|
||||||
|
|
||||||
|
### 5.4 Runtime Updates
|
||||||
|
|
||||||
|
When an admin saves settings via `PUT /api/admin/settings/{module}`:
|
||||||
|
|
||||||
|
1. Validate the payload against the module's config schema.
|
||||||
|
2. Write changed keys to `settings_overrides`.
|
||||||
|
3. Update `module_state` if `enabled` changed.
|
||||||
|
4. Apply changes to the in-memory config (hot reload where safe).
|
||||||
|
5. Broadcast `settings.changed` SSE event with `{module, enabled, changed_keys}`.
|
||||||
|
6. For changes that require restart (e.g., `server.port`, `database.*`), return a `restart_required: true` flag in the response. The UI shows a banner.
|
||||||
|
|
||||||
|
### 5.5 What Requires Restart
|
||||||
|
|
||||||
|
| Config Area | Hot Reload | Restart Required |
|
||||||
|
|-------------|-----------|------------------|
|
||||||
|
| Module enable/disable | Yes | No |
|
||||||
|
| `auth.*` provider toggles | Yes | No |
|
||||||
|
| `auth.cors.allowed_origins` | Yes | No |
|
||||||
|
| `odoo.*` connection settings | Yes | No |
|
||||||
|
| `freecad.*` | Yes | No |
|
||||||
|
| `jobs.*` timeouts, directory | Yes | No |
|
||||||
|
| `server.host`, `server.port` | No | Yes |
|
||||||
|
| `database.*` | No | Yes |
|
||||||
|
| `storage.*` | No | Yes |
|
||||||
|
| `schemas.directory` | No | Yes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Public Module Discovery Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/modules
|
||||||
|
```
|
||||||
|
|
||||||
|
**No authentication required.** Clients need this pre-login to know whether OIDC is available, whether projects exist, etc.
|
||||||
|
|
||||||
|
### 6.1 Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"modules": {
|
||||||
|
"core": {
|
||||||
|
"enabled": true,
|
||||||
|
"required": true,
|
||||||
|
"name": "Core PDM",
|
||||||
|
"version": "0.2"
|
||||||
|
},
|
||||||
|
"schemas": {
|
||||||
|
"enabled": true,
|
||||||
|
"required": true,
|
||||||
|
"name": "Schemas"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"enabled": true,
|
||||||
|
"required": true,
|
||||||
|
"name": "Storage"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"enabled": true,
|
||||||
|
"required": false,
|
||||||
|
"name": "Authentication",
|
||||||
|
"config": {
|
||||||
|
"local_enabled": true,
|
||||||
|
"ldap_enabled": true,
|
||||||
|
"oidc_enabled": true,
|
||||||
|
"oidc_issuer_url": "https://keycloak.example.com/realms/silo"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"enabled": true,
|
||||||
|
"required": false,
|
||||||
|
"name": "Projects"
|
||||||
|
},
|
||||||
|
"audit": {
|
||||||
|
"enabled": true,
|
||||||
|
"required": false,
|
||||||
|
"name": "Audit"
|
||||||
|
},
|
||||||
|
"odoo": {
|
||||||
|
"enabled": false,
|
||||||
|
"required": false,
|
||||||
|
"name": "Odoo ERP"
|
||||||
|
},
|
||||||
|
"freecad": {
|
||||||
|
"enabled": true,
|
||||||
|
"required": false,
|
||||||
|
"name": "Create Integration",
|
||||||
|
"config": {
|
||||||
|
"uri_scheme": "silo"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"jobs": {
|
||||||
|
"enabled": false,
|
||||||
|
"required": false,
|
||||||
|
"name": "Job Queue"
|
||||||
|
},
|
||||||
|
"dag": {
|
||||||
|
"enabled": false,
|
||||||
|
"required": false,
|
||||||
|
"name": "Dependency DAG",
|
||||||
|
"depends_on": ["jobs"]
|
||||||
|
},
|
||||||
|
"solver": {
|
||||||
|
"enabled": false,
|
||||||
|
"required": false,
|
||||||
|
"name": "Solver",
|
||||||
|
"depends_on": ["jobs"]
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"enabled": true,
|
||||||
|
"required": false,
|
||||||
|
"name": "Sessions",
|
||||||
|
"depends_on": ["auth"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"version": "0.2",
|
||||||
|
"read_only": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `config` sub-object exposes only public, non-secret metadata needed by clients. Never includes passwords, tokens, or secret keys.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Admin Settings Endpoints
|
||||||
|
|
||||||
|
### 7.1 Get All Settings
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/settings
|
||||||
|
Authorization: Bearer <admin token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns full config grouped by module with secrets redacted:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"core": {
|
||||||
|
"server": {
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 8080,
|
||||||
|
"base_url": "https://silo.example.com",
|
||||||
|
"read_only": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {
|
||||||
|
"directory": "/etc/silo/schemas",
|
||||||
|
"default": "kindred-rd"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"backend": "filesystem",
|
||||||
|
"filesystem": {
|
||||||
|
"root_dir": "/var/lib/silo/data"
|
||||||
|
},
|
||||||
|
"status": "connected"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"host": "postgres",
|
||||||
|
"port": 5432,
|
||||||
|
"name": "silo",
|
||||||
|
"user": "silo",
|
||||||
|
"password": "****",
|
||||||
|
"sslmode": "disable",
|
||||||
|
"max_connections": 10,
|
||||||
|
"status": "connected"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"enabled": true,
|
||||||
|
"session_secret": "****",
|
||||||
|
"local": { "enabled": true },
|
||||||
|
"ldap": {
|
||||||
|
"enabled": true,
|
||||||
|
"url": "ldaps://ipa.example.com",
|
||||||
|
"base_dn": "dc=kindred,dc=internal",
|
||||||
|
"user_search_dn": "cn=users,cn=accounts,dc=kindred,dc=internal",
|
||||||
|
"bind_password": "****",
|
||||||
|
"role_mapping": { "...": "..." }
|
||||||
|
},
|
||||||
|
"oidc": {
|
||||||
|
"enabled": true,
|
||||||
|
"issuer_url": "https://keycloak.example.com/realms/silo",
|
||||||
|
"client_id": "silo",
|
||||||
|
"client_secret": "****",
|
||||||
|
"redirect_url": "https://silo.example.com/auth/callback"
|
||||||
|
},
|
||||||
|
"cors": { "allowed_origins": ["https://silo.example.com"] }
|
||||||
|
},
|
||||||
|
"projects": { "enabled": true },
|
||||||
|
"audit": { "enabled": true },
|
||||||
|
"odoo": { "enabled": false, "url": "", "database": "", "username": "" },
|
||||||
|
"freecad": { "uri_scheme": "silo", "executable": "" },
|
||||||
|
"jobs": {
|
||||||
|
"enabled": false,
|
||||||
|
"directory": "/etc/silo/jobdefs",
|
||||||
|
"runner_timeout": 90,
|
||||||
|
"job_timeout_check": 30,
|
||||||
|
"default_priority": 100
|
||||||
|
},
|
||||||
|
"dag": { "enabled": false },
|
||||||
|
"solver": { "enabled": false, "default_solver": "ondsel" },
|
||||||
|
"sessions": { "enabled": true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Get Module Settings
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/settings/{module}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns just the module's config block.
|
||||||
|
|
||||||
|
### 7.3 Update Module Settings
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/admin/settings/{module}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"ldap": {
|
||||||
|
"enabled": true,
|
||||||
|
"url": "ldaps://ipa.example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"updated": ["auth.ldap.enabled", "auth.ldap.url"],
|
||||||
|
"restart_required": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 Test Connectivity
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/admin/settings/{module}/test
|
||||||
|
```
|
||||||
|
|
||||||
|
Available for modules with external connections:
|
||||||
|
|
||||||
|
| Module | Test Action |
|
||||||
|
|--------|------------|
|
||||||
|
| `storage` | Verify filesystem storage directory is accessible |
|
||||||
|
| `auth` (ldap) | Attempt LDAP bind with configured credentials |
|
||||||
|
| `auth` (oidc) | Fetch OIDC discovery document from issuer URL |
|
||||||
|
| `odoo` | Attempt XML-RPC connection to Odoo |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "LDAP bind successful",
|
||||||
|
"latency_ms": 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Config YAML Changes
|
||||||
|
|
||||||
|
The existing `config.yaml` gains a `modules` section. Existing top-level keys remain for backward compatibility — the module system reads from both locations.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Existing keys (unchanged, still work)
|
||||||
|
server:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
database:
|
||||||
|
host: postgres
|
||||||
|
port: 5432
|
||||||
|
name: silo
|
||||||
|
user: silo
|
||||||
|
password: silodev
|
||||||
|
sslmode: disable
|
||||||
|
|
||||||
|
storage:
|
||||||
|
backend: filesystem
|
||||||
|
filesystem:
|
||||||
|
root_dir: /var/lib/silo/data
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
directory: /etc/silo/schemas
|
||||||
|
|
||||||
|
auth:
|
||||||
|
enabled: true
|
||||||
|
session_secret: change-me
|
||||||
|
local:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# New: explicit module toggles (optional, defaults shown)
|
||||||
|
modules:
|
||||||
|
projects:
|
||||||
|
enabled: true
|
||||||
|
audit:
|
||||||
|
enabled: true
|
||||||
|
odoo:
|
||||||
|
enabled: false
|
||||||
|
freecad:
|
||||||
|
enabled: true
|
||||||
|
uri_scheme: silo
|
||||||
|
jobs:
|
||||||
|
enabled: false
|
||||||
|
directory: /etc/silo/jobdefs
|
||||||
|
runner_timeout: 90
|
||||||
|
job_timeout_check: 30
|
||||||
|
default_priority: 100
|
||||||
|
dag:
|
||||||
|
enabled: false
|
||||||
|
solver:
|
||||||
|
enabled: false
|
||||||
|
default_solver: ondsel
|
||||||
|
sessions:
|
||||||
|
enabled: true
|
||||||
|
```
|
||||||
|
|
||||||
|
If a module is not listed under `modules:`, its default enabled state from Section 2.2 applies. The `auth.enabled` field continues to control the `auth` module (no duplication under `modules:`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. SSE Events
|
||||||
|
|
||||||
|
```
|
||||||
|
settings.changed {module, enabled, changed_keys[], updated_by}
|
||||||
|
```
|
||||||
|
|
||||||
|
Broadcast on any admin settings change. The web UI listens for this to:
|
||||||
|
|
||||||
|
- Show/hide navigation entries when modules are toggled.
|
||||||
|
- Display a "Settings updated by another admin" toast.
|
||||||
|
- Show a "Restart required" banner when flagged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Web UI — Admin Settings Page
|
||||||
|
|
||||||
|
The Settings page (`/settings`) is restructured into sections:
|
||||||
|
|
||||||
|
### 10.1 Existing (unchanged)
|
||||||
|
|
||||||
|
- **Account** — username, display name, email, auth source, role badge.
|
||||||
|
- **API Tokens** — create, list, revoke.
|
||||||
|
|
||||||
|
### 10.2 New: Module Configuration (admin only)
|
||||||
|
|
||||||
|
Visible only to admin users. Each module gets a collapsible card:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ [toggle] Authentication [status] │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ── Local Auth ──────────────────────────────────── │
|
||||||
|
│ Enabled: [toggle] │
|
||||||
|
│ │
|
||||||
|
│ ── LDAP / FreeIPA ──────────────────────────────── │
|
||||||
|
│ Enabled: [toggle] │
|
||||||
|
│ URL: [ldaps://ipa.example.com ] │
|
||||||
|
│ Base DN: [dc=kindred,dc=internal ] [Test] │
|
||||||
|
│ │
|
||||||
|
│ ── OIDC / Keycloak ────────────────────────────── │
|
||||||
|
│ Enabled: [toggle] │
|
||||||
|
│ Issuer URL: [https://keycloak.example.com] [Test] │
|
||||||
|
│ Client ID: [silo ] │
|
||||||
|
│ │
|
||||||
|
│ ── CORS ────────────────────────────────────────── │
|
||||||
|
│ Allowed Origins: [tag input] │
|
||||||
|
│ │
|
||||||
|
│ [Save] │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Module cards for required modules (`core`, `schemas`, `storage`) show their status and config but have no enable/disable toggle.
|
||||||
|
|
||||||
|
Status indicators per module:
|
||||||
|
|
||||||
|
| Status | Badge | Meaning |
|
||||||
|
|--------|-------|---------|
|
||||||
|
| Active | `green` | Enabled and operational |
|
||||||
|
| Disabled | `overlay1` | Toggled off |
|
||||||
|
| Error | `red` | Enabled but connectivity or config issue |
|
||||||
|
| Setup Required | `yellow` | Enabled but missing required config (e.g., LDAP URL empty) |
|
||||||
|
|
||||||
|
### 10.3 Infrastructure Section (admin, read-only)
|
||||||
|
|
||||||
|
Shows connection status for required infrastructure:
|
||||||
|
|
||||||
|
- **Database** — host, port, name, connection pool usage, status badge.
|
||||||
|
- **Storage** — endpoint, bucket, SSL, status badge.
|
||||||
|
|
||||||
|
These are read-only in the UI (setup-only via YAML/env). The "Test" button is available to verify connectivity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Implementation Order
|
||||||
|
|
||||||
|
1. **Migration 014** — `settings_overrides` and `module_state` tables.
|
||||||
|
2. **Config loader refactor** — YAML → DB merge → env override pipeline.
|
||||||
|
3. **Module registry** — Go struct defining all modules with metadata, dependencies, defaults.
|
||||||
|
4. **`GET /api/modules`** — public endpoint, no auth.
|
||||||
|
5. **`RequireModule` middleware** — gate route groups by module state.
|
||||||
|
6. **Admin settings API** — `GET/PUT /api/admin/settings/{module}`, test endpoints.
|
||||||
|
7. **Web UI settings page** — module cards with toggles, config forms, test buttons.
|
||||||
|
8. **SSE integration** — `settings.changed` event broadcast.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Future Considerations
|
||||||
|
|
||||||
|
- **Module manifest format** — per ROADMAP.md, each module will eventually declare routes, views, hooks, and permissions via a manifest. This spec covers the runtime module registry; the manifest format is TBD.
|
||||||
|
- **Custom modules** — third-party modules that register against the endpoint registry. Requires the manifest contract and a plugin loading mechanism.
|
||||||
|
- **Per-module permissions** — beyond the current role hierarchy, modules may define fine-grained scopes (e.g., `jobs:admin`, `dag:write`).
|
||||||
|
- **Location & Inventory module** — when the Location/Inventory API is implemented (tables already exist), it becomes a new optional module.
|
||||||
|
- **Notifications module** — per ROADMAP.md Tier 1, notifications/subscriptions will be a dedicated module.
|
||||||
|
- **Soft interference detection** — the `sessions` module currently enforces hard interference (unique index on item + context_level + object_id). Soft interference detection (overlapping dependency cones) is planned as a follow-up.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. References
|
||||||
|
|
||||||
|
- [CONFIGURATION.md](CONFIGURATION.md) — Current config reference
|
||||||
|
- [ROADMAP.md](ROADMAP.md) — Module manifest, API endpoint registry
|
||||||
|
- [AUTH.md](AUTH.md) — Authentication architecture
|
||||||
|
- [WORKERS.md](WORKERS.md) — Job queue system
|
||||||
|
- [DAG.md](DAG.md) — Dependency DAG specification
|
||||||
|
- [SPECIFICATION.md](SPECIFICATION.md) — Full endpoint listing
|
||||||
@@ -88,11 +88,11 @@ Everything depends on these. They define what Silo *is*.
|
|||||||
| Component | Description | Status |
|
| Component | Description | Status |
|
||||||
|-----------|-------------|--------|
|
|-----------|-------------|--------|
|
||||||
| **Core Silo** | Part/assembly storage, version control, auth, base REST API | Complete |
|
| **Core Silo** | Part/assembly storage, version control, auth, base REST API | Complete |
|
||||||
| **.kc Format Spec** | File format contract between Create and Silo | Not Started |
|
| **.kc Format Spec** | File format contract between Create and Silo | Complete |
|
||||||
| **API Endpoint Registry** | Module discovery, dynamic UI rendering, health checks | Not Started |
|
| **API Endpoint Registry** | Module discovery, dynamic UI rendering, health checks | Not Started |
|
||||||
| **Web UI Shell** | App launcher, breadcrumbs, view framework, module rendering | Partial |
|
| **Web UI Shell** | App launcher, breadcrumbs, view framework, module rendering | Partial |
|
||||||
| **Python Scripting Engine** | Server-side hook execution, module extension point | Not Started |
|
| **Python Scripting Engine** | Server-side hook execution, module extension point | Not Started |
|
||||||
| **Job Queue Infrastructure** | Redis/NATS shared async service for all compute modules | Not Started |
|
| **Job Queue Infrastructure** | PostgreSQL-backed async job queue with runner management | Complete |
|
||||||
|
|
||||||
### Tier 1 -- Core Services
|
### Tier 1 -- Core Services
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ Broad downstream dependencies. These should be built early because retrofitting
|
|||||||
|--------|-------------|------------|--------|
|
|--------|-------------|------------|--------|
|
||||||
| **Headless Create** | API-driven FreeCAD instance for file manipulation, geometry queries, format conversion, rendering | Core Silo, Job Queue | Not Started |
|
| **Headless Create** | API-driven FreeCAD instance for file manipulation, geometry queries, format conversion, rendering | Core Silo, Job Queue | Not Started |
|
||||||
| **Notifications & Subscriptions** | Per-part watch lists, lifecycle event hooks, webhook delivery | Core Silo, Registry | Not Started |
|
| **Notifications & Subscriptions** | Per-part watch lists, lifecycle event hooks, webhook delivery | Core Silo, Registry | Not Started |
|
||||||
| **Audit Trail / Compliance** | ITAR, ISO 9001, AS9100 traceability; module-level event journaling | Core Silo | Partial |
|
| **Audit Trail / Compliance** | ITAR, ISO 9001, AS9100 traceability; module-level event journaling | Core Silo | Complete (base) |
|
||||||
|
|
||||||
### Tier 2 -- File Intelligence & Collaboration
|
### Tier 2 -- File Intelligence & Collaboration
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ Process modules that formalize how engineering work moves through an organizatio
|
|||||||
|
|
||||||
| Module | Description | Depends On | Status |
|
| Module | Description | Depends On | Status |
|
||||||
|--------|-------------|------------|--------|
|
|--------|-------------|------------|--------|
|
||||||
| **Approval / ECO Workflow** | Engineering change orders, multi-stage review gates, digital signatures | Notifications, Audit Trail, Schemas | Not Started |
|
| **Approval / ECO Workflow** | Engineering change orders, multi-stage review gates, digital signatures | Notifications, Audit Trail, Schemas | Complete |
|
||||||
| **Shop Floor Drawing Distribution** | Controlled push-to-production drawings; web-based appliance displays on the floor | Headless Create, Approval Workflow | Not Started |
|
| **Shop Floor Drawing Distribution** | Controlled push-to-production drawings; web-based appliance displays on the floor | Headless Create, Approval Workflow | Not Started |
|
||||||
| **Import/Export Bridge** | STEP, IGES, 3MF connectors; SOLIDWORKS migration tooling; ERP adapters | Headless Create | Not Started |
|
| **Import/Export Bridge** | STEP, IGES, 3MF connectors; SOLIDWORKS migration tooling; ERP adapters | Headless Create | Not Started |
|
||||||
| **Multi-tenant / Org Management** | Org boundaries, role-based permissioning, storage quotas | Core Auth, Audit Trail | Not Started |
|
| **Multi-tenant / Org Management** | Org boundaries, role-based permissioning, storage quotas | Core Auth, Audit Trail | Not Started |
|
||||||
@@ -202,15 +202,15 @@ Implement engineering change processes (Tier 4: Approval/ECO Workflow).
|
|||||||
|
|
||||||
| Task | Description | Status |
|
| Task | Description | Status |
|
||||||
|------|-------------|--------|
|
|------|-------------|--------|
|
||||||
| Workflow designer | YAML-defined state machines | Not Started |
|
| Workflow designer | YAML-defined state machines | Complete |
|
||||||
| State transitions | Configurable transition rules with permissions | Not Started |
|
| State transitions | Configurable transition rules with permissions | Complete |
|
||||||
| Approval workflows | Single and parallel approver gates | Not Started |
|
| Approval workflows | Single and parallel approver gates | Complete |
|
||||||
| Email notifications | SMTP integration for alerts on state changes | Not Started |
|
| Email notifications | SMTP integration for alerts on state changes | Not Started |
|
||||||
|
|
||||||
**Success metrics:**
|
**Success metrics:**
|
||||||
- Engineering change process completable in Silo
|
- ~~Engineering change process completable in Silo~~ Done (YAML-configured workflows with multi-stage gates)
|
||||||
- Email notifications delivered reliably
|
- Email notifications delivered reliably
|
||||||
- Workflow state visible in web UI
|
- ~~Workflow state visible in web UI~~ Available via API
|
||||||
|
|
||||||
### Search & Discovery
|
### Search & Discovery
|
||||||
|
|
||||||
@@ -240,9 +240,17 @@ For full SOLIDWORKS PDM comparison tables, see [GAP_ANALYSIS.md Appendix C](GAP_
|
|||||||
5. ~~Multi-level BOM API~~ -- recursive expansion with configurable depth
|
5. ~~Multi-level BOM API~~ -- recursive expansion with configurable depth
|
||||||
6. ~~BOM export~~ -- CSV and ODS formats
|
6. ~~BOM export~~ -- CSV and ODS formats
|
||||||
|
|
||||||
|
### Recently Completed
|
||||||
|
|
||||||
|
7. ~~Workflow engine~~ -- YAML-defined state machines with multi-stage approval gates
|
||||||
|
8. ~~Job queue~~ -- PostgreSQL-backed async compute with runner management
|
||||||
|
9. ~~Assembly solver service~~ -- server-side constraint solving with result caching
|
||||||
|
10. ~~Workstation registration~~ -- device identity and heartbeat tracking
|
||||||
|
11. ~~Edit sessions~~ -- acquire/release with hard interference detection
|
||||||
|
|
||||||
### Critical Gaps (Required for Team Use)
|
### Critical Gaps (Required for Team Use)
|
||||||
|
|
||||||
1. **Workflow engine** -- state machines with transitions and approvals
|
1. ~~**Workflow engine**~~ -- Complete (YAML-configured approval workflows)
|
||||||
2. **Check-out locking** -- pessimistic locking for CAD files
|
2. **Check-out locking** -- pessimistic locking for CAD files
|
||||||
|
|
||||||
### High Priority Gaps (Significant Value)
|
### High Priority Gaps (Significant Value)
|
||||||
@@ -275,7 +283,7 @@ For full SOLIDWORKS PDM comparison tables, see [GAP_ANALYSIS.md Appendix C](GAP_
|
|||||||
|
|
||||||
1. **Module manifest format** -- JSON, TOML, or Python-based? Tradeoffs between simplicity and expressiveness.
|
1. **Module manifest format** -- JSON, TOML, or Python-based? Tradeoffs between simplicity and expressiveness.
|
||||||
2. **.kc thumbnail policy** -- Single canonical thumbnail vs. multi-view renders. Impacts file size and generation cost.
|
2. **.kc thumbnail policy** -- Single canonical thumbnail vs. multi-view renders. Impacts file size and generation cost.
|
||||||
3. **Job queue technology** -- Redis Streams vs. NATS. Redis is already in the stack; NATS offers better pub/sub semantics for event-driven modules.
|
3. ~~**Job queue technology**~~ -- Resolved: PostgreSQL-backed with `SELECT FOR UPDATE SKIP LOCKED` for exactly-once delivery. No external queue dependency.
|
||||||
4. **Headless Create deployment** -- Sidecar container per Silo instance, or pool of workers behind the job queue?
|
4. **Headless Create deployment** -- Sidecar container per Silo instance, or pool of workers behind the job queue?
|
||||||
5. **BIM-MES workbench scope** -- How much of FreeCAD BIM is reusable vs. needs to be purpose-built for inventory/facility modeling?
|
5. **BIM-MES workbench scope** -- How much of FreeCAD BIM is reusable vs. needs to be purpose-built for inventory/facility modeling?
|
||||||
6. **Offline .kc workflow** -- How much of the `silo/` metadata is authoritative when disconnected? Reconciliation strategy on reconnect.
|
6. **Offline .kc workflow** -- How much of the `silo/` metadata is authoritative when disconnected? Reconciliation strategy on reconnect.
|
||||||
@@ -287,7 +295,7 @@ For full SOLIDWORKS PDM comparison tables, see [GAP_ANALYSIS.md Appendix C](GAP_
|
|||||||
### Implemented Features (MVP Complete)
|
### Implemented Features (MVP Complete)
|
||||||
|
|
||||||
#### Core Database System
|
#### Core Database System
|
||||||
- PostgreSQL schema with 13 migrations
|
- PostgreSQL schema with 23 migrations
|
||||||
- UUID-based identifiers throughout
|
- UUID-based identifiers throughout
|
||||||
- Soft delete support via `archived_at` timestamps
|
- Soft delete support via `archived_at` timestamps
|
||||||
- Atomic sequence generation for part numbers
|
- Atomic sequence generation for part numbers
|
||||||
@@ -340,7 +348,7 @@ For full SOLIDWORKS PDM comparison tables, see [GAP_ANALYSIS.md Appendix C](GAP_
|
|||||||
- Template generation for import formatting
|
- Template generation for import formatting
|
||||||
|
|
||||||
#### API & Web Interface
|
#### API & Web Interface
|
||||||
- REST API with 78 endpoints
|
- REST API with ~140 endpoints
|
||||||
- Authentication: local (bcrypt), LDAP/FreeIPA, OIDC/Keycloak
|
- Authentication: local (bcrypt), LDAP/FreeIPA, OIDC/Keycloak
|
||||||
- Role-based access control (admin > editor > viewer)
|
- Role-based access control (admin > editor > viewer)
|
||||||
- API token management (SHA-256 hashed)
|
- API token management (SHA-256 hashed)
|
||||||
@@ -371,7 +379,7 @@ For full SOLIDWORKS PDM comparison tables, see [GAP_ANALYSIS.md Appendix C](GAP_
|
|||||||
| Part number validation | Not started | API accepts but doesn't validate format |
|
| Part number validation | Not started | API accepts but doesn't validate format |
|
||||||
| Location hierarchy CRUD | Schema only | Tables exist, no API endpoints |
|
| Location hierarchy CRUD | Schema only | Tables exist, no API endpoints |
|
||||||
| Inventory tracking | Schema only | Tables exist, no API endpoints |
|
| Inventory tracking | Schema only | Tables exist, no API endpoints |
|
||||||
| Unit tests | Partial | 11 Go test files across api, db, ods, partnum, schema packages |
|
| Unit tests | Partial | 31 Go test files across api, db, modules, ods, partnum, schema packages |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
912
docs/SOLVER.md
Normal file
912
docs/SOLVER.md
Normal file
@@ -0,0 +1,912 @@
|
|||||||
|
# Solver Service Specification
|
||||||
|
|
||||||
|
**Status:** Phase 3b Implemented (server endpoints, job definitions, result cache)
|
||||||
|
**Last Updated:** 2026-03-01
|
||||||
|
**Depends on:** KCSolve Phase 1 (PR #297), Phase 2 (PR #298)
|
||||||
|
**Prerequisite infrastructure:** Job queue, runner system, and SSE broadcasting are fully implemented (see [WORKERS.md](WORKERS.md), migration `015_jobs_runners.sql`, `cmd/silorunner/`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
The solver service extends Silo's job queue system with assembly constraint solving capabilities. It enables server-side solving of assemblies stored in Silo, with results streamed back to clients in real time via SSE.
|
||||||
|
|
||||||
|
This specification describes how the existing KCSolve client-side API (C++ library + pybind11 `kcsolve` module) integrates with Silo's worker infrastructure to provide headless, asynchronous constraint solving.
|
||||||
|
|
||||||
|
### 1.1 Goals
|
||||||
|
|
||||||
|
1. **Offload solving** -- Move heavy solve operations off the user's machine to server workers.
|
||||||
|
2. **Batch validation** -- Automatically validate assemblies on commit (e.g. check for over-constrained systems).
|
||||||
|
3. **Solver selection** -- Allow the server to run different solvers than the client (e.g. a more thorough solver for validation, a fast one for interactive editing).
|
||||||
|
4. **Standalone execution** -- Solver workers can run without a full FreeCAD installation, using just the `kcsolve` Python module and the `.kc` file.
|
||||||
|
|
||||||
|
### 1.2 Non-Goals
|
||||||
|
|
||||||
|
- **Interactive drag** -- Real-time drag solving stays client-side (latency-sensitive).
|
||||||
|
- **Geometry processing** -- Workers don't compute geometry; they receive pre-extracted constraint graphs.
|
||||||
|
- **Solver development** -- Writing new solver backends is out of scope; this spec covers the transport and execution layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Kindred Create │
|
||||||
|
│ (FreeCAD client) │
|
||||||
|
└───────┬──────────────┘
|
||||||
|
│ 1. POST /api/solver/jobs
|
||||||
|
│ (SolveContext JSON)
|
||||||
|
│
|
||||||
|
│ 4. GET /api/events (SSE)
|
||||||
|
│ job.progress, job.completed
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Silo Server │
|
||||||
|
│ (silod) │
|
||||||
|
│ │
|
||||||
|
│ solver module │
|
||||||
|
│ REST + SSE + queue │
|
||||||
|
└───────┬──────────────┘
|
||||||
|
│ 2. POST /api/runner/claim
|
||||||
|
│ 3. POST /api/runner/jobs/{id}/complete
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Solver Runner │
|
||||||
|
│ (silorunner) │
|
||||||
|
│ │
|
||||||
|
│ kcsolve module │
|
||||||
|
│ OndselAdapter │
|
||||||
|
│ Python solvers │
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.1 Components
|
||||||
|
|
||||||
|
| Component | Role | Deployment |
|
||||||
|
|-----------|------|------------|
|
||||||
|
| **Silo server** | Job queue management, REST API, SSE broadcast, result storage | Existing `silod` binary (jobs module, migration 015) |
|
||||||
|
| **Solver runner** | Claims solver jobs, executes `kcsolve`, reports results | Existing `silorunner` binary (`cmd/silorunner/`) with `solver` tag |
|
||||||
|
| **kcsolve module** | Python/C++ solver library (Phase 1+2) | Installed on runner nodes |
|
||||||
|
| **Create client** | Submits jobs, receives results via SSE | Existing FreeCAD client |
|
||||||
|
|
||||||
|
### 2.2 Module Registration
|
||||||
|
|
||||||
|
The solver service is a Silo module with ID `solver`, gated behind the existing module system:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# config.yaml
|
||||||
|
modules:
|
||||||
|
solver:
|
||||||
|
enabled: true
|
||||||
|
```
|
||||||
|
|
||||||
|
It depends on the `jobs` module being enabled. All solver endpoints return `404` with `{"error": "module not enabled"}` when disabled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Data Model
|
||||||
|
|
||||||
|
### 3.1 SolveContext JSON Schema
|
||||||
|
|
||||||
|
The `SolveContext` is the input to a solve operation. Currently it exists only as a C++ struct and pybind11 binding with no serialization. Phase 3 adds JSON serialization to enable server transport.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_version": 1,
|
||||||
|
"parts": [
|
||||||
|
{
|
||||||
|
"id": "Part001",
|
||||||
|
"placement": {
|
||||||
|
"position": [0.0, 0.0, 0.0],
|
||||||
|
"quaternion": [1.0, 0.0, 0.0, 0.0]
|
||||||
|
},
|
||||||
|
"mass": 1.0,
|
||||||
|
"grounded": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Part002",
|
||||||
|
"placement": {
|
||||||
|
"position": [100.0, 0.0, 0.0],
|
||||||
|
"quaternion": [1.0, 0.0, 0.0, 0.0]
|
||||||
|
},
|
||||||
|
"mass": 1.0,
|
||||||
|
"grounded": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"constraints": [
|
||||||
|
{
|
||||||
|
"id": "Joint001",
|
||||||
|
"part_i": "Part001",
|
||||||
|
"marker_i": {
|
||||||
|
"position": [50.0, 0.0, 0.0],
|
||||||
|
"quaternion": [1.0, 0.0, 0.0, 0.0]
|
||||||
|
},
|
||||||
|
"part_j": "Part002",
|
||||||
|
"marker_j": {
|
||||||
|
"position": [0.0, 0.0, 0.0],
|
||||||
|
"quaternion": [1.0, 0.0, 0.0, 0.0]
|
||||||
|
},
|
||||||
|
"type": "Revolute",
|
||||||
|
"params": [],
|
||||||
|
"limits": [],
|
||||||
|
"activated": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"motions": [],
|
||||||
|
"simulation": null,
|
||||||
|
"bundle_fixed": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Field reference:** See [KCSolve Python API](../reference/kcsolve-python.md) for full field documentation. The JSON schema maps 1:1 to the Python/C++ types.
|
||||||
|
|
||||||
|
**Enum serialization:** Enums serialize as strings matching their Python names (e.g. `"Revolute"`, `"Success"`, `"Redundant"`).
|
||||||
|
|
||||||
|
**Transform shorthand:** The `placement` and `marker_*` fields use the `Transform` struct: `position` is `[x, y, z]`, `quaternion` is `[w, x, y, z]`.
|
||||||
|
|
||||||
|
**Constraint.Limit:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "RotationMin",
|
||||||
|
"value": -1.5708,
|
||||||
|
"tolerance": 1e-9
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**MotionDef:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "Rotational",
|
||||||
|
"joint_id": "Joint001",
|
||||||
|
"marker_i": "",
|
||||||
|
"marker_j": "",
|
||||||
|
"rotation_expr": "2*pi*t",
|
||||||
|
"translation_expr": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**SimulationParams:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"t_start": 0.0,
|
||||||
|
"t_end": 2.0,
|
||||||
|
"h_out": 0.04,
|
||||||
|
"h_min": 1e-9,
|
||||||
|
"h_max": 1.0,
|
||||||
|
"error_tol": 1e-6
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 SolveResult JSON Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "Success",
|
||||||
|
"placements": [
|
||||||
|
{
|
||||||
|
"id": "Part002",
|
||||||
|
"placement": {
|
||||||
|
"position": [50.0, 0.0, 0.0],
|
||||||
|
"quaternion": [0.707, 0.0, 0.707, 0.0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dof": 1,
|
||||||
|
"diagnostics": [
|
||||||
|
{
|
||||||
|
"constraint_id": "Joint003",
|
||||||
|
"kind": "Redundant",
|
||||||
|
"detail": "6 DOF removed by Joint003 are already constrained"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"num_frames": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Solver Job Record
|
||||||
|
|
||||||
|
Solver jobs are stored in the existing `jobs` table. The solver-specific data is in the `args` and `result` JSONB columns.
|
||||||
|
|
||||||
|
**Job args (input):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"solver": "ondsel",
|
||||||
|
"operation": "solve",
|
||||||
|
"context": { /* SolveContext JSON */ },
|
||||||
|
"item_part_number": "ASM-001",
|
||||||
|
"revision_number": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Operation types:**
|
||||||
|
| Operation | Description | Requires simulation? |
|
||||||
|
|-----------|-------------|---------------------|
|
||||||
|
| `solve` | Static equilibrium solve | No |
|
||||||
|
| `diagnose` | Constraint analysis only (no placement update) | No |
|
||||||
|
| `kinematic` | Time-domain kinematic simulation | Yes |
|
||||||
|
|
||||||
|
**Job result (output):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": { /* SolveResult JSON */ },
|
||||||
|
"solver_name": "OndselSolver (Lagrangian)",
|
||||||
|
"solver_version": "1.0",
|
||||||
|
"solve_time_ms": 127.4
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. REST API
|
||||||
|
|
||||||
|
All endpoints are prefixed with `/api/solver/` and gated behind `RequireModule("solver")`.
|
||||||
|
|
||||||
|
### 4.1 Submit Solve Job
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/solver/jobs
|
||||||
|
Authorization: Bearer silo_...
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"solver": "ondsel",
|
||||||
|
"operation": "solve",
|
||||||
|
"context": { /* SolveContext */ },
|
||||||
|
"priority": 50
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Optional fields:**
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `solver` | string | `""` (default solver) | Solver name from registry |
|
||||||
|
| `operation` | string | `"solve"` | `solve`, `diagnose`, or `kinematic` |
|
||||||
|
| `context` | object | required | SolveContext JSON |
|
||||||
|
| `priority` | int | `50` | Lower = higher priority |
|
||||||
|
| `item_part_number` | string | `null` | Silo item reference (for result association) |
|
||||||
|
| `revision_number` | int | `null` | Revision that generated this context |
|
||||||
|
| `callback_url` | string | `null` | Webhook URL for completion notification |
|
||||||
|
|
||||||
|
**Response `201 Created`:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"job_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2026-02-19T18:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error responses:**
|
||||||
|
| Code | Condition |
|
||||||
|
|------|-----------|
|
||||||
|
| `400` | Invalid SolveContext (missing required fields, unknown enum values) |
|
||||||
|
| `401` | Not authenticated |
|
||||||
|
| `404` | Module not enabled |
|
||||||
|
| `422` | Unknown solver name, invalid operation |
|
||||||
|
|
||||||
|
### 4.2 Get Job Status
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/solver/jobs/{jobID}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response `200 OK`:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"job_id": "550e8400-...",
|
||||||
|
"status": "completed",
|
||||||
|
"operation": "solve",
|
||||||
|
"solver": "ondsel",
|
||||||
|
"priority": 50,
|
||||||
|
"item_part_number": "ASM-001",
|
||||||
|
"revision_number": 3,
|
||||||
|
"runner_id": "runner-01",
|
||||||
|
"runner_name": "solver-worker-01",
|
||||||
|
"created_at": "2026-02-19T18:30:00Z",
|
||||||
|
"claimed_at": "2026-02-19T18:30:01Z",
|
||||||
|
"completed_at": "2026-02-19T18:30:02Z",
|
||||||
|
"result": {
|
||||||
|
"result": { /* SolveResult */ },
|
||||||
|
"solver_name": "OndselSolver (Lagrangian)",
|
||||||
|
"solve_time_ms": 127.4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 List Solver Jobs
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/solver/jobs?status=completed&item=ASM-001&limit=20&offset=0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query parameters:**
|
||||||
|
| Param | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `status` | string | Filter by status: `pending`, `claimed`, `running`, `completed`, `failed` |
|
||||||
|
| `item` | string | Filter by item part number |
|
||||||
|
| `operation` | string | Filter by operation type |
|
||||||
|
| `solver` | string | Filter by solver name |
|
||||||
|
| `limit` | int | Page size (default 20, max 100) |
|
||||||
|
| `offset` | int | Pagination offset |
|
||||||
|
|
||||||
|
**Response `200 OK`:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jobs": [ /* array of job objects */ ],
|
||||||
|
"total": 42,
|
||||||
|
"limit": 20,
|
||||||
|
"offset": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Cancel Job
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/solver/jobs/{jobID}/cancel
|
||||||
|
```
|
||||||
|
|
||||||
|
Only `pending` and `claimed` jobs can be cancelled. Running jobs must complete or time out.
|
||||||
|
|
||||||
|
**Response `200 OK`:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"job_id": "550e8400-...",
|
||||||
|
"status": "cancelled"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Get Solver Registry
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/solver/solvers
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns available solvers on registered runners. Runners report their solver capabilities during heartbeat.
|
||||||
|
|
||||||
|
**Response `200 OK`:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"solvers": [
|
||||||
|
{
|
||||||
|
"name": "ondsel",
|
||||||
|
"display_name": "OndselSolver (Lagrangian)",
|
||||||
|
"deterministic": true,
|
||||||
|
"supported_joints": [
|
||||||
|
"Coincident", "Fixed", "Revolute", "Cylindrical",
|
||||||
|
"Slider", "Ball", "Screw", "Gear", "RackPinion",
|
||||||
|
"Parallel", "Perpendicular", "Angle", "Planar",
|
||||||
|
"Concentric", "PointOnLine", "PointInPlane",
|
||||||
|
"LineInPlane", "Tangent", "DistancePointPoint",
|
||||||
|
"DistanceCylSph", "Universal"
|
||||||
|
],
|
||||||
|
"runner_count": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default_solver": "ondsel"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Server-Sent Events
|
||||||
|
|
||||||
|
Solver jobs emit events on the existing `/api/events` SSE stream.
|
||||||
|
|
||||||
|
### 5.1 Event Types
|
||||||
|
|
||||||
|
Solver jobs use the existing `job.*` SSE event prefix (see [WORKERS.md](WORKERS.md)). Clients filter on `definition_name` to identify solver-specific events.
|
||||||
|
|
||||||
|
| Event | Payload | When |
|
||||||
|
|-------|---------|------|
|
||||||
|
| `job.created` | `{job_id, definition_name, trigger, item_id}` | Job submitted |
|
||||||
|
| `job.claimed` | `{job_id, runner_id, runner}` | Runner claims work |
|
||||||
|
| `job.progress` | `{job_id, progress, message}` | Progress update (0-100) |
|
||||||
|
| `job.completed` | `{job_id, runner_id}` | Job succeeded |
|
||||||
|
| `job.failed` | `{job_id, runner_id, error}` | Job failed |
|
||||||
|
|
||||||
|
### 5.2 Example Stream
|
||||||
|
|
||||||
|
```
|
||||||
|
event: job.created
|
||||||
|
data: {"job_id":"abc-123","definition_name":"assembly-solve","trigger":"manual","item_id":"uuid-..."}
|
||||||
|
|
||||||
|
event: job.claimed
|
||||||
|
data: {"job_id":"abc-123","runner_id":"r1","runner":"solver-worker-01"}
|
||||||
|
|
||||||
|
event: job.progress
|
||||||
|
data: {"job_id":"abc-123","progress":50,"message":"Building constraint system..."}
|
||||||
|
|
||||||
|
event: job.completed
|
||||||
|
data: {"job_id":"abc-123","runner_id":"r1"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Client Integration
|
||||||
|
|
||||||
|
The Create client subscribes to the SSE stream and updates the Assembly workbench UI:
|
||||||
|
|
||||||
|
1. **Silo viewport widget** shows job status indicator (pending/running/done/failed)
|
||||||
|
2. On `job.completed` (where `definition_name` starts with `assembly-`), the client fetches the full result via `GET /api/jobs/{id}` and applies placements
|
||||||
|
3. On `job.failed`, the client shows the error in the report panel
|
||||||
|
4. Diagnostic results (redundant/conflicting constraints) surface in the constraint tree
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Runner Integration
|
||||||
|
|
||||||
|
### 6.1 Runner Requirements
|
||||||
|
|
||||||
|
Solver runners are standard `silorunner` instances (see `cmd/silorunner/main.go`) registered with the `solver` tag. The existing runner binary already handles the full job lifecycle (claim, start, progress, complete/fail, log, DAG sync). Solver support requires adding `solver-run`, `solver-diagnose`, and `solver-kinematic` to the runner's command dispatch (currently handles `create-validate`, `create-export`, `create-dag-extract`, `create-thumbnail`).
|
||||||
|
|
||||||
|
Additional requirements on the runner host:
|
||||||
|
|
||||||
|
- Python 3.11+ with `kcsolve` module installed
|
||||||
|
- `libKCSolve.so` and solver backend libraries (e.g. `libOndselSolver.so`)
|
||||||
|
- Network access to the Silo server
|
||||||
|
|
||||||
|
No FreeCAD installation is required. The runner operates on pre-extracted `SolveContext` JSON.
|
||||||
|
|
||||||
|
### 6.2 Runner Registration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Register a solver runner (admin)
|
||||||
|
curl -X POST https://silo.example.com/api/runners \
|
||||||
|
-H "Authorization: Bearer admin_token" \
|
||||||
|
-d '{"name":"solver-01","tags":["solver"]}'
|
||||||
|
|
||||||
|
# Response includes one-time token
|
||||||
|
{"id":"uuid","token":"silo_runner_xyz..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Runner Heartbeat and Capabilities
|
||||||
|
|
||||||
|
The existing heartbeat endpoint (`POST /api/runner/heartbeat`) takes no body — it updates `last_heartbeat` on every authenticated request via the `RequireRunnerAuth` middleware. Runners that go 90 seconds without a request are marked offline by the background sweeper.
|
||||||
|
|
||||||
|
Solver capabilities are reported via the runner's `metadata` JSONB field, set at registration time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://silo.example.com/api/runners \
|
||||||
|
-H "Authorization: Bearer admin_token" \
|
||||||
|
-d '{
|
||||||
|
"name": "solver-01",
|
||||||
|
"tags": ["solver"],
|
||||||
|
"metadata": {
|
||||||
|
"solvers": ["ondsel"],
|
||||||
|
"api_version": 1,
|
||||||
|
"python_version": "3.11.11"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Future enhancement:** The heartbeat endpoint could be extended to accept an optional body for dynamic capability updates, but currently capabilities are static per registration.
|
||||||
|
|
||||||
|
### 6.4 Runner Execution Flow
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Solver runner entry point."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import kcsolve
|
||||||
|
|
||||||
|
|
||||||
|
def execute_solve_job(args: dict) -> dict:
|
||||||
|
"""Execute a solver job from parsed args."""
|
||||||
|
solver_name = args.get("solver", "")
|
||||||
|
operation = args.get("operation", "solve")
|
||||||
|
ctx_dict = args["context"]
|
||||||
|
|
||||||
|
# Deserialize SolveContext from JSON
|
||||||
|
ctx = kcsolve.SolveContext.from_dict(ctx_dict)
|
||||||
|
|
||||||
|
# Load solver
|
||||||
|
solver = kcsolve.load(solver_name)
|
||||||
|
if solver is None:
|
||||||
|
raise ValueError(f"Unknown solver: {solver_name!r}")
|
||||||
|
|
||||||
|
# Execute operation
|
||||||
|
if operation == "solve":
|
||||||
|
result = solver.solve(ctx)
|
||||||
|
elif operation == "diagnose":
|
||||||
|
diags = solver.diagnose(ctx)
|
||||||
|
result = kcsolve.SolveResult()
|
||||||
|
result.diagnostics = diags
|
||||||
|
elif operation == "kinematic":
|
||||||
|
result = solver.run_kinematic(ctx)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown operation: {operation!r}")
|
||||||
|
|
||||||
|
# Serialize result
|
||||||
|
return {
|
||||||
|
"result": result.to_dict(),
|
||||||
|
"solver_name": solver.name(),
|
||||||
|
"solver_version": "1.0",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 Standalone Process Mode
|
||||||
|
|
||||||
|
For minimal deployments, the runner can invoke a standalone solver process:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo '{"solver":"ondsel","operation":"solve","context":{...}}' | \
|
||||||
|
python3 -m kcsolve.runner
|
||||||
|
```
|
||||||
|
|
||||||
|
The `kcsolve.runner` module reads JSON from stdin, executes the solve, and writes the result JSON to stdout. Exit code 0 = success, non-zero = failure with error JSON on stderr.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Job Definitions
|
||||||
|
|
||||||
|
### 7.1 Manual Solve Job
|
||||||
|
|
||||||
|
Triggered by the client when the user requests a server-side solve.
|
||||||
|
|
||||||
|
> **Note:** The `compute.type` uses `custom` because the valid types in `internal/jobdef/jobdef.go` are: `validate`, `rebuild`, `diff`, `export`, `custom`. Solver commands are dispatched by the runner based on the `command` field.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
job:
|
||||||
|
name: assembly-solve
|
||||||
|
version: 1
|
||||||
|
description: "Solve assembly constraints on server"
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
type: manual
|
||||||
|
|
||||||
|
scope:
|
||||||
|
type: assembly
|
||||||
|
|
||||||
|
compute:
|
||||||
|
type: custom
|
||||||
|
command: solver-run
|
||||||
|
|
||||||
|
runner:
|
||||||
|
tags: [solver]
|
||||||
|
|
||||||
|
timeout: 300
|
||||||
|
max_retries: 1
|
||||||
|
priority: 50
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Commit-Time Validation
|
||||||
|
|
||||||
|
Automatically validates assembly constraints when a new revision is committed:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
job:
|
||||||
|
name: assembly-validate
|
||||||
|
version: 1
|
||||||
|
description: "Validate assembly constraints on commit"
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
type: revision_created
|
||||||
|
filter:
|
||||||
|
item_type: assembly
|
||||||
|
|
||||||
|
scope:
|
||||||
|
type: assembly
|
||||||
|
|
||||||
|
compute:
|
||||||
|
type: custom
|
||||||
|
command: solver-diagnose
|
||||||
|
args:
|
||||||
|
operation: diagnose
|
||||||
|
|
||||||
|
runner:
|
||||||
|
tags: [solver]
|
||||||
|
|
||||||
|
timeout: 120
|
||||||
|
max_retries: 2
|
||||||
|
priority: 75
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Kinematic Simulation
|
||||||
|
|
||||||
|
Server-side kinematic simulation for assemblies with motion definitions:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
job:
|
||||||
|
name: assembly-kinematic
|
||||||
|
version: 1
|
||||||
|
description: "Run kinematic simulation"
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
type: manual
|
||||||
|
|
||||||
|
scope:
|
||||||
|
type: assembly
|
||||||
|
|
||||||
|
compute:
|
||||||
|
type: custom
|
||||||
|
command: solver-kinematic
|
||||||
|
args:
|
||||||
|
operation: kinematic
|
||||||
|
|
||||||
|
runner:
|
||||||
|
tags: [solver]
|
||||||
|
|
||||||
|
timeout: 1800
|
||||||
|
max_retries: 0
|
||||||
|
priority: 100
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. SolveContext Extraction
|
||||||
|
|
||||||
|
When a solver job is triggered by a revision commit (rather than a direct context submission), the server or runner must extract a `SolveContext` from the `.kc` file.
|
||||||
|
|
||||||
|
### 8.1 Extraction via Headless Create
|
||||||
|
|
||||||
|
For full-fidelity extraction that handles geometry classification:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
create --console -e "
|
||||||
|
import kcsolve_extract
|
||||||
|
kcsolve_extract.extract_and_solve('input.kc', 'output.json', solver='ondsel')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires a full Create installation on the runner and uses the Assembly module's existing adapter layer to build `SolveContext` from document objects.
|
||||||
|
|
||||||
|
### 8.2 Extraction from .kc Silo Directory
|
||||||
|
|
||||||
|
For lightweight extraction without FreeCAD, the constraint graph can be stored in the `.kc` archive's `silo/` directory during commit:
|
||||||
|
|
||||||
|
```
|
||||||
|
silo/solver/context.json # Pre-extracted SolveContext
|
||||||
|
silo/solver/result.json # Last solve result (if any)
|
||||||
|
```
|
||||||
|
|
||||||
|
The client extracts the `SolveContext` locally before committing the `.kc` file. The server reads it from the archive, avoiding the need for geometry processing on the runner.
|
||||||
|
|
||||||
|
**Commit-time packing** (client side):
|
||||||
|
```python
|
||||||
|
# In the Assembly workbench commit hook:
|
||||||
|
ctx = assembly_object.build_solve_context()
|
||||||
|
kc_archive.write("silo/solver/context.json", ctx.to_json())
|
||||||
|
```
|
||||||
|
|
||||||
|
**Runner-side extraction:**
|
||||||
|
```python
|
||||||
|
import zipfile, json
|
||||||
|
|
||||||
|
with zipfile.ZipFile("assembly.kc") as zf:
|
||||||
|
ctx_json = json.loads(zf.read("silo/solver/context.json"))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Database Schema
|
||||||
|
|
||||||
|
### 9.1 Migration
|
||||||
|
|
||||||
|
The solver module uses the existing `jobs` table. One new table is added for result caching:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Migration: 021_solver_results.sql
|
||||||
|
|
||||||
|
CREATE TABLE solver_results (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
revision_number INTEGER NOT NULL,
|
||||||
|
job_id UUID REFERENCES jobs(id) ON DELETE SET NULL,
|
||||||
|
operation TEXT NOT NULL, -- 'solve', 'diagnose', 'kinematic'
|
||||||
|
solver_name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL, -- SolveStatus string
|
||||||
|
dof INTEGER,
|
||||||
|
diagnostics JSONB DEFAULT '[]',
|
||||||
|
placements JSONB DEFAULT '[]',
|
||||||
|
num_frames INTEGER DEFAULT 0,
|
||||||
|
solve_time_ms DOUBLE PRECISION,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(item_id, revision_number, operation)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_solver_results_item ON solver_results(item_id);
|
||||||
|
CREATE INDEX idx_solver_results_status ON solver_results(status);
|
||||||
|
```
|
||||||
|
|
||||||
|
The `UNIQUE(item_id, revision_number, operation)` constraint means each revision has at most one result per operation type. Re-running overwrites the previous result.
|
||||||
|
|
||||||
|
### 9.2 Result Association
|
||||||
|
|
||||||
|
When a solver job completes, the server:
|
||||||
|
1. Stores the full result in the `jobs.result` JSONB column (standard job result)
|
||||||
|
2. Upserts a row in `solver_results` for quick lookup by item/revision
|
||||||
|
3. Broadcasts `job.completed` SSE event
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Configuration
|
||||||
|
|
||||||
|
### 10.1 Server Config
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# config.yaml
|
||||||
|
modules:
|
||||||
|
solver:
|
||||||
|
enabled: true
|
||||||
|
default_solver: "ondsel"
|
||||||
|
max_context_size_mb: 10 # Reject oversized SolveContext payloads
|
||||||
|
default_timeout: 300 # Default job timeout (seconds)
|
||||||
|
auto_diagnose_on_commit: true # Auto-submit diagnose job on revision commit
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `SILO_SOLVER_ENABLED` | Override module enabled state |
|
||||||
|
| `SILO_SOLVER_DEFAULT` | Default solver name |
|
||||||
|
|
||||||
|
### 10.3 Runner Config
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# runner.yaml
|
||||||
|
server_url: https://silo.example.com
|
||||||
|
token: silo_runner_xyz...
|
||||||
|
tags: [solver]
|
||||||
|
|
||||||
|
solver:
|
||||||
|
kcsolve_path: /opt/create/lib # LD_LIBRARY_PATH for kcsolve.so
|
||||||
|
python: /opt/create/bin/python3
|
||||||
|
max_concurrent: 2 # Parallel job slots per runner
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Security
|
||||||
|
|
||||||
|
### 11.1 Authentication
|
||||||
|
|
||||||
|
All solver endpoints use the existing Silo authentication:
|
||||||
|
- **User endpoints** (`/api/solver/jobs`): Session or API token, requires `viewer` role to read, `editor` role to submit
|
||||||
|
- **Runner endpoints** (`/api/runner/...`): Runner token authentication (existing)
|
||||||
|
|
||||||
|
### 11.2 Input Validation
|
||||||
|
|
||||||
|
The server validates SolveContext JSON before queuing:
|
||||||
|
- Maximum payload size (configurable, default 10 MB)
|
||||||
|
- Required fields present (`parts`, `constraints`)
|
||||||
|
- Enum values are valid strings
|
||||||
|
- Transform arrays have correct length (position: 3, quaternion: 4)
|
||||||
|
- No duplicate part or constraint IDs
|
||||||
|
|
||||||
|
### 11.3 Runner Isolation
|
||||||
|
|
||||||
|
Solver runners execute untrusted constraint data. Mitigations:
|
||||||
|
- Runners should run in containers or sandboxed environments
|
||||||
|
- Python solver registration (`kcsolve.register_solver()`) is disabled in runner mode
|
||||||
|
- Solver execution has a configurable timeout (killed on expiry)
|
||||||
|
- Result size is bounded (large kinematic simulations are truncated)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Client SDK
|
||||||
|
|
||||||
|
### 12.1 Python Client
|
||||||
|
|
||||||
|
The existing `silo-client` package is extended with solver methods:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from silo_client import SiloClient
|
||||||
|
|
||||||
|
client = SiloClient("https://silo.example.com", token="silo_...")
|
||||||
|
|
||||||
|
# Submit a solve job
|
||||||
|
import kcsolve
|
||||||
|
ctx = kcsolve.SolveContext()
|
||||||
|
# ... build context ...
|
||||||
|
|
||||||
|
job = client.solver.submit(ctx.to_dict(), solver="ondsel")
|
||||||
|
print(job.id, job.status) # "pending"
|
||||||
|
|
||||||
|
# Poll for completion
|
||||||
|
result = client.solver.wait(job.id, timeout=60)
|
||||||
|
print(result.status) # "Success"
|
||||||
|
|
||||||
|
# Or use SSE for real-time updates
|
||||||
|
for event in client.solver.stream(job.id):
|
||||||
|
print(event.type, event.data)
|
||||||
|
|
||||||
|
# Query results for an item
|
||||||
|
results = client.solver.results("ASM-001")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.2 Create Workbench Integration
|
||||||
|
|
||||||
|
The Assembly workbench adds a "Solve on Server" command:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# CommandSolveOnServer.py (sketch)
|
||||||
|
def activated(self):
|
||||||
|
assembly = get_active_assembly()
|
||||||
|
ctx = assembly.build_solve_context()
|
||||||
|
|
||||||
|
# Submit to Silo
|
||||||
|
from silo_client import get_client
|
||||||
|
client = get_client()
|
||||||
|
job = client.solver.submit(ctx.to_dict())
|
||||||
|
|
||||||
|
# Subscribe to SSE for updates
|
||||||
|
self.watch_job(job.id)
|
||||||
|
|
||||||
|
def on_solver_completed(self, job_id, result):
|
||||||
|
# Apply placements back to assembly
|
||||||
|
assembly = get_active_assembly()
|
||||||
|
for pr in result["placements"]:
|
||||||
|
assembly.set_part_placement(pr["id"], pr["placement"])
|
||||||
|
assembly.recompute()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Implementation Plan
|
||||||
|
|
||||||
|
### Phase 3a: JSON Serialization
|
||||||
|
|
||||||
|
Add `to_dict()` / `from_dict()` methods to all KCSolve types in the pybind11 module.
|
||||||
|
|
||||||
|
**Files to modify:**
|
||||||
|
- `src/Mod/Assembly/Solver/bindings/kcsolve_py.cpp` -- add dict conversion methods
|
||||||
|
|
||||||
|
**Verification:** `ctx.to_dict()` round-trips through `SolveContext.from_dict()`.
|
||||||
|
|
||||||
|
### Phase 3b: Server Endpoints -- COMPLETE
|
||||||
|
|
||||||
|
Add the solver module to the Silo server. This builds on the existing job queue infrastructure (`migration 015_jobs_runners.sql`, `internal/db/jobs.go`, `internal/api/job_handlers.go`, `internal/api/runner_handlers.go`).
|
||||||
|
|
||||||
|
**Implemented files:**
|
||||||
|
- `internal/api/solver_handlers.go` -- REST endpoint handlers (solver-specific convenience layer over existing `/api/jobs`)
|
||||||
|
- `internal/db/migrations/021_solver_results.sql` -- Database migration for result caching table
|
||||||
|
- Module registered as `solver` in `internal/modules/modules.go` with `jobs` dependency
|
||||||
|
|
||||||
|
### Phase 3c: Runner Support
|
||||||
|
|
||||||
|
Add solver command handlers to the existing `silorunner` binary (`cmd/silorunner/main.go`). The runner already implements the full job lifecycle (claim, start, progress, complete/fail). This phase adds `solver-run`, `solver-diagnose`, and `solver-kinematic` to the `executeJob` switch statement.
|
||||||
|
|
||||||
|
**Files to modify:**
|
||||||
|
- `cmd/silorunner/main.go` -- Add solver command dispatch cases
|
||||||
|
- `src/Mod/Assembly/Solver/bindings/runner.py` -- `kcsolve.runner` Python entry point (invoked by silorunner via subprocess)
|
||||||
|
|
||||||
|
### Phase 3d: .kc Context Packing
|
||||||
|
|
||||||
|
Pack `SolveContext` into `.kc` archives on commit.
|
||||||
|
|
||||||
|
**Files to modify:**
|
||||||
|
- `mods/silo/freecad/silo_origin.py` -- Hook into commit to pack solver context
|
||||||
|
|
||||||
|
### Phase 3e: Client Integration
|
||||||
|
|
||||||
|
Add "Solve on Server" command to the Assembly workbench.
|
||||||
|
|
||||||
|
**Files to modify:**
|
||||||
|
- `mods/silo/freecad/` -- Solver client methods
|
||||||
|
- `src/Mod/Assembly/` -- Server solve command
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Open Questions
|
||||||
|
|
||||||
|
1. **Context size limits** -- Large assemblies may produce multi-MB SolveContext JSON. Should we compress (gzip) or use a binary format (msgpack)?
|
||||||
|
|
||||||
|
2. **Result persistence** -- How long should solver results be retained? Per-revision (overwritten on next commit) or historical (keep all)?
|
||||||
|
|
||||||
|
3. **Kinematic frame storage** -- Kinematic simulations can produce thousands of frames. Store all frames in JSONB, or write to a separate file and reference it?
|
||||||
|
|
||||||
|
4. **Multi-solver comparison** -- Should the API support running the same context through multiple solvers and comparing results? Useful for Phase 4 (second solver validation).
|
||||||
|
|
||||||
|
5. **Webhook notifications** -- The `callback_url` field allows external integrations (e.g. CI). What authentication should the webhook use?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. References
|
||||||
|
|
||||||
|
- [KCSolve Architecture](../architecture/ondsel-solver.md)
|
||||||
|
- [KCSolve Python API Reference](../reference/kcsolve-python.md)
|
||||||
|
- [INTER_SOLVER.md](../../INTER_SOLVER.md) -- Full pluggable solver spec
|
||||||
|
- [WORKERS.md](WORKERS.md) -- Worker/runner job system
|
||||||
|
- [SPECIFICATION.md](SPECIFICATION.md) -- Silo server specification
|
||||||
|
- [MODULES.md](MODULES.md) -- Module system
|
||||||
@@ -37,7 +37,7 @@ Silo treats **part numbering schemas as configuration, not code**. Multiple numb
|
|||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ Silo Server (silod) │
|
│ Silo Server (silod) │
|
||||||
│ - REST API (78 endpoints) │
|
│ - REST API (86 endpoints) │
|
||||||
│ - Authentication (local, LDAP, OIDC) │
|
│ - Authentication (local, LDAP, OIDC) │
|
||||||
│ - Schema parsing and validation │
|
│ - Schema parsing and validation │
|
||||||
│ - Part number generation engine │
|
│ - Part number generation engine │
|
||||||
@@ -598,7 +598,7 @@ See [AUTH.md](AUTH.md) for full architecture details and [AUTH_USER_GUIDE.md](AU
|
|||||||
|
|
||||||
## 11. API Design
|
## 11. API Design
|
||||||
|
|
||||||
### 11.1 REST Endpoints (78 Implemented)
|
### 11.1 REST Endpoints (86 Implemented)
|
||||||
|
|
||||||
```
|
```
|
||||||
# Health (no auth)
|
# Health (no auth)
|
||||||
@@ -697,6 +697,20 @@ POST /api/items/{partNumber}/bom/merge # Merge BOM from ODS
|
|||||||
PUT /api/items/{partNumber}/bom/{childPartNumber} # Update BOM entry [editor]
|
PUT /api/items/{partNumber}/bom/{childPartNumber} # Update BOM entry [editor]
|
||||||
DELETE /api/items/{partNumber}/bom/{childPartNumber} # Remove BOM entry [editor]
|
DELETE /api/items/{partNumber}/bom/{childPartNumber} # Remove BOM entry [editor]
|
||||||
|
|
||||||
|
# .kc Metadata (read: viewer, write: editor)
|
||||||
|
GET /api/items/{partNumber}/metadata # Get indexed .kc metadata
|
||||||
|
PUT /api/items/{partNumber}/metadata # Update metadata fields [editor]
|
||||||
|
PATCH /api/items/{partNumber}/metadata/lifecycle # Transition lifecycle state [editor]
|
||||||
|
PATCH /api/items/{partNumber}/metadata/tags # Add/remove tags [editor]
|
||||||
|
|
||||||
|
# .kc Dependencies (viewer)
|
||||||
|
GET /api/items/{partNumber}/dependencies # List raw dependencies
|
||||||
|
GET /api/items/{partNumber}/dependencies/resolve # Resolve UUIDs to part numbers + file availability
|
||||||
|
|
||||||
|
# .kc Macros (viewer)
|
||||||
|
GET /api/items/{partNumber}/macros # List registered macros
|
||||||
|
GET /api/items/{partNumber}/macros/{filename} # Get macro source content
|
||||||
|
|
||||||
# Audit (viewer)
|
# Audit (viewer)
|
||||||
GET /api/audit/completeness # Item completeness scores
|
GET /api/audit/completeness # Item completeness scores
|
||||||
GET /api/audit/completeness/{partNumber} # Item detail breakdown
|
GET /api/audit/completeness/{partNumber} # Item detail breakdown
|
||||||
@@ -735,6 +749,139 @@ POST /api/inventory/{partNumber}/move
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 11.3 .kc File Integration
|
||||||
|
|
||||||
|
Silo supports the `.kc` file format — a ZIP archive that is a superset of FreeCAD's `.fcstd`. A `.kc` file contains everything an `.fcstd` does, plus a `silo/` directory with platform metadata.
|
||||||
|
|
||||||
|
#### Standard entries (preserved as-is)
|
||||||
|
|
||||||
|
`Document.xml`, `GuiDocument.xml`, BREP geometry files (`.brp`), `thumbnails/`
|
||||||
|
|
||||||
|
#### Silo entries (`silo/` directory)
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `silo/manifest.json` | Instance origin, part UUID, revision hash, `.kc` schema version |
|
||||||
|
| `silo/metadata.json` | Custom schema field values, tags, lifecycle state |
|
||||||
|
| `silo/history.json` | Local revision log (server-generated on checkout) |
|
||||||
|
| `silo/dependencies.json` | Assembly link references by Silo UUID |
|
||||||
|
| `silo/macros/*.py` | Embedded macro scripts bound to this part |
|
||||||
|
|
||||||
|
#### Commit-time extraction
|
||||||
|
|
||||||
|
When a `.kc` file is uploaded via `POST /api/items/{partNumber}/file`, the server:
|
||||||
|
|
||||||
|
1. Opens the ZIP and scans for `silo/` entries
|
||||||
|
2. Parses `silo/manifest.json` and validates the UUID matches the item
|
||||||
|
3. Upserts `silo/metadata.json` fields into the `item_metadata` table
|
||||||
|
4. Replaces `silo/dependencies.json` entries in the `item_dependencies` table
|
||||||
|
5. Replaces `silo/macros/*.py` entries in the `item_macros` table
|
||||||
|
6. Broadcasts SSE events: `metadata.updated`, `dependencies.changed`, `macros.changed`
|
||||||
|
|
||||||
|
Extraction is best-effort — failures are logged as warnings but do not block the upload.
|
||||||
|
|
||||||
|
#### Checkout-time packing
|
||||||
|
|
||||||
|
When a `.kc` file is downloaded via `GET /api/items/{partNumber}/file/{revision}`, the server repacks the `silo/` directory with current database state:
|
||||||
|
|
||||||
|
- `silo/manifest.json` — current item UUID and metadata freshness
|
||||||
|
- `silo/metadata.json` — latest schema fields, tags, lifecycle state
|
||||||
|
- `silo/history.json` — last 20 revisions from the database
|
||||||
|
- `silo/dependencies.json` — current dependency list from `item_dependencies`
|
||||||
|
|
||||||
|
Non-silo ZIP entries are passed through unchanged. If the file is a plain `.fcstd` (no `silo/` directory), it is served as-is.
|
||||||
|
|
||||||
|
ETag caching: the server computes an ETag from `revision_number:metadata.updated_at` and returns `304 Not Modified` when the client's `If-None-Match` header matches.
|
||||||
|
|
||||||
|
#### Lifecycle state machine
|
||||||
|
|
||||||
|
The `lifecycle_state` field in `item_metadata` follows this state machine:
|
||||||
|
|
||||||
|
```
|
||||||
|
draft → review → released → obsolete
|
||||||
|
↑ ↓
|
||||||
|
└────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Valid transitions are enforced by `PATCH /metadata/lifecycle`. Invalid transitions return `422 Unprocessable Entity`.
|
||||||
|
|
||||||
|
#### Metadata response shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_name": "kindred-rd",
|
||||||
|
"lifecycle_state": "draft",
|
||||||
|
"tags": ["prototype", "v2"],
|
||||||
|
"fields": {"material": "AL6061", "finish": "anodized"},
|
||||||
|
"manifest": {
|
||||||
|
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"silo_instance": "silo.example.com",
|
||||||
|
"revision_hash": "abc123",
|
||||||
|
"kc_version": "1.0"
|
||||||
|
},
|
||||||
|
"updated_at": "2026-02-18T12:00:00Z",
|
||||||
|
"updated_by": "forbes"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Dependency response shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"uuid": "550e8400-...",
|
||||||
|
"part_number": "F01-0042",
|
||||||
|
"revision": 3,
|
||||||
|
"quantity": 4.0,
|
||||||
|
"label": "M5 Bolt",
|
||||||
|
"relationship": "component"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Resolved dependency response shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"uuid": "550e8400-...",
|
||||||
|
"part_number": "F01-0042",
|
||||||
|
"label": "M5 Bolt",
|
||||||
|
"revision": 3,
|
||||||
|
"quantity": 4.0,
|
||||||
|
"resolved": true,
|
||||||
|
"file_available": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Macro list response shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"filename": "validate_dims.py", "trigger": "manual", "revision_number": 5}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Macro detail response shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filename": "validate_dims.py",
|
||||||
|
"trigger": "manual",
|
||||||
|
"content": "import FreeCAD\n...",
|
||||||
|
"revision_number": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Database tables (migration 018)
|
||||||
|
|
||||||
|
- `item_metadata` — schema fields, lifecycle state, tags, manifest info
|
||||||
|
- `item_dependencies` — parent/child UUID references with quantity and relationship type
|
||||||
|
- `item_macros` — filename, trigger type, source content, indexed per item
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 12. MVP Scope
|
## 12. MVP Scope
|
||||||
|
|
||||||
### 12.1 Implemented
|
### 12.1 Implemented
|
||||||
@@ -743,7 +890,7 @@ POST /api/inventory/{partNumber}/move
|
|||||||
- [x] YAML schema parser for part numbering
|
- [x] YAML schema parser for part numbering
|
||||||
- [x] Part number generation engine
|
- [x] Part number generation engine
|
||||||
- [x] CLI tool (`cmd/silo`)
|
- [x] CLI tool (`cmd/silo`)
|
||||||
- [x] API server (`cmd/silod`) with 78 endpoints
|
- [x] API server (`cmd/silod`) with 86 endpoints
|
||||||
- [x] Filesystem-based file storage
|
- [x] Filesystem-based file storage
|
||||||
- [x] BOM relationships (component, alternate, reference)
|
- [x] BOM relationships (component, alternate, reference)
|
||||||
- [x] Multi-level BOM (recursive expansion with configurable depth)
|
- [x] Multi-level BOM (recursive expansion with configurable depth)
|
||||||
@@ -765,6 +912,12 @@ POST /api/inventory/{partNumber}/move
|
|||||||
- [x] Audit logging and completeness scoring
|
- [x] Audit logging and completeness scoring
|
||||||
- [x] CSRF protection (nosurf)
|
- [x] CSRF protection (nosurf)
|
||||||
- [x] Fuzzy search
|
- [x] Fuzzy search
|
||||||
|
- [x] .kc file extraction pipeline (metadata, dependencies, macros indexed on commit)
|
||||||
|
- [x] .kc file packing on checkout (manifest, metadata, history, dependencies)
|
||||||
|
- [x] .kc metadata API (get, update fields, lifecycle transitions, tags)
|
||||||
|
- [x] .kc dependency API (list, resolve with file availability)
|
||||||
|
- [x] .kc macro API (list, get source content)
|
||||||
|
- [x] ETag caching for .kc file downloads
|
||||||
- [x] Property schema versioning framework
|
- [x] Property schema versioning framework
|
||||||
- [x] Docker Compose deployment (dev and prod)
|
- [x] Docker Compose deployment (dev and prod)
|
||||||
- [x] systemd service and deployment scripts
|
- [x] systemd service and deployment scripts
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Silo Development Status
|
# Silo Development Status
|
||||||
|
|
||||||
**Last Updated:** 2026-02-08
|
**Last Updated:** 2026-03-01
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -10,10 +10,10 @@
|
|||||||
|
|
||||||
| Component | Status | Notes |
|
| Component | Status | Notes |
|
||||||
|-----------|--------|-------|
|
|-----------|--------|-------|
|
||||||
| PostgreSQL schema | Complete | 13 migrations applied |
|
| PostgreSQL schema | Complete | 23 migrations applied |
|
||||||
| YAML schema parser | Complete | Supports enum, serial, constant, string segments |
|
| YAML schema parser | Complete | Supports enum, serial, constant, string segments |
|
||||||
| Part number generator | Complete | Scoped sequences, category-based format |
|
| Part number generator | Complete | Scoped sequences, category-based format |
|
||||||
| API server (`silod`) | Complete | 78 REST endpoints via chi/v5 |
|
| API server (`silod`) | Complete | ~140 REST endpoints via chi/v5 |
|
||||||
| CLI tool (`silo`) | Complete | Item registration and management |
|
| CLI tool (`silo`) | Complete | Item registration and management |
|
||||||
| Filesystem file storage | Complete | Upload, download, checksums |
|
| Filesystem file storage | Complete | Upload, download, checksums |
|
||||||
| Revision control | Complete | Append-only history, rollback, comparison, status/labels |
|
| Revision control | Complete | Append-only history, rollback, comparison, status/labels |
|
||||||
@@ -30,6 +30,16 @@
|
|||||||
| Fuzzy search | Complete | sahilm/fuzzy library |
|
| Fuzzy search | Complete | sahilm/fuzzy library |
|
||||||
| Web UI | Complete | React SPA (Vite + TypeScript), 6 pages, Catppuccin Mocha theme |
|
| Web UI | Complete | React SPA (Vite + TypeScript), 6 pages, Catppuccin Mocha theme |
|
||||||
| File attachments | Complete | Direct uploads, item file association, thumbnails |
|
| File attachments | Complete | Direct uploads, item file association, thumbnails |
|
||||||
|
| .kc extraction pipeline | Complete | Metadata, dependencies, macros indexed on commit |
|
||||||
|
| .kc checkout packing | Complete | Manifest, metadata, history, dependencies repacked on download |
|
||||||
|
| .kc metadata API | Complete | GET/PUT metadata, lifecycle transitions, tag management |
|
||||||
|
| .kc dependency API | Complete | List raw deps, resolve UUIDs to part numbers + file availability |
|
||||||
|
| .kc macro API | Complete | List macros, get source content by filename |
|
||||||
|
| Approval workflows | Complete | YAML-configurable ECO workflows, multi-stage review gates, digital signatures |
|
||||||
|
| Solver service | Complete | Server-side assembly constraint solving, result caching, job definitions |
|
||||||
|
| Workstation registration | Complete | Device identity, heartbeat tracking, per-user workstation management |
|
||||||
|
| Edit sessions | Complete | Acquire/release locks, hard interference detection, SSE notifications |
|
||||||
|
| SSE targeted delivery | Complete | Per-item, per-user, per-workstation event filtering |
|
||||||
| Odoo ERP integration | Partial | Config and sync-log CRUD functional; push/pull are stubs |
|
| Odoo ERP integration | Partial | Config and sync-log CRUD functional; push/pull are stubs |
|
||||||
| Docker Compose | Complete | Dev and production configurations |
|
| Docker Compose | Complete | Dev and production configurations |
|
||||||
| Deployment scripts | Complete | setup-host, deploy, init-db, setup-ipa-nginx |
|
| Deployment scripts | Complete | setup-host, deploy, init-db, setup-ipa-nginx |
|
||||||
@@ -47,7 +57,7 @@ FreeCAD workbench and LibreOffice Calc extension are maintained in separate repo
|
|||||||
| Inventory API endpoints | Database tables exist, no REST handlers |
|
| Inventory API endpoints | Database tables exist, no REST handlers |
|
||||||
| Date segment type | Schema parser placeholder only |
|
| Date segment type | Schema parser placeholder only |
|
||||||
| Part number format validation | API accepts but does not validate format on creation |
|
| Part number format validation | API accepts but does not validate format on creation |
|
||||||
| Unit tests | 9 Go test files across api, db, ods, partnum, schema packages |
|
| Unit tests | 31 Go test files across api, db, modules, ods, partnum, schema packages |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -96,3 +106,13 @@ The schema defines 170 category codes across 10 groups:
|
|||||||
| 011_item_files.sql | Item file attachments (item_files table, thumbnail_key column) |
|
| 011_item_files.sql | Item file attachments (item_files table, thumbnail_key column) |
|
||||||
| 012_bom_source.sql | BOM entry source tracking |
|
| 012_bom_source.sql | BOM entry source tracking |
|
||||||
| 013_move_cost_sourcing_to_props.sql | Move sourcing_link and standard_cost from item columns to revision properties |
|
| 013_move_cost_sourcing_to_props.sql | Move sourcing_link and standard_cost from item columns to revision properties |
|
||||||
|
| 014_settings.sql | Settings overrides and module state tables |
|
||||||
|
| 015_jobs.sql | Job queue, runner, and job log tables |
|
||||||
|
| 016_dag.sql | Dependency DAG nodes and edges |
|
||||||
|
| 017_locations.sql | Location hierarchy and inventory tracking |
|
||||||
|
| 018_kc_metadata.sql | .kc metadata tables (item_metadata, item_dependencies, item_macros, item_approvals, approval_signatures) |
|
||||||
|
| 019_approval_workflow_name.sql | Approval workflow name column |
|
||||||
|
| 020_storage_backend_filesystem_default.sql | Storage backend default to filesystem |
|
||||||
|
| 021_solver_results.sql | Solver result caching table |
|
||||||
|
| 022_workstations.sql | Workstation registration table |
|
||||||
|
| 023_edit_sessions.sql | Edit session tracking table with hard interference unique index |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Worker System Specification
|
# Worker System Specification
|
||||||
|
|
||||||
**Status:** Draft
|
**Status:** Implemented
|
||||||
**Last Updated:** 2026-02-13
|
**Last Updated:** 2026-03-01
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ Supporting files:
|
|||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `web/src/components/items/CategoryPicker.tsx` | Multi-stage domain/subcategory selector |
|
| `web/src/components/items/CategoryPicker.tsx` | Multi-stage domain/subcategory selector |
|
||||||
| `web/src/components/items/FileDropZone.tsx` | Drag-and-drop file upload with MinIO presigned URLs |
|
| `web/src/components/items/FileDropZone.tsx` | Drag-and-drop file upload |
|
||||||
| `web/src/components/items/TagInput.tsx` | Multi-select tag input for projects |
|
| `web/src/components/items/TagInput.tsx` | Multi-select tag input for projects |
|
||||||
| `web/src/hooks/useFormDescriptor.ts` | Fetches and caches form descriptor from `/api/schemas/{name}/form` |
|
| `web/src/hooks/useFormDescriptor.ts` | Fetches and caches form descriptor from `/api/schemas/{name}/form` |
|
||||||
| `web/src/hooks/useFileUpload.ts` | Manages presigned URL upload flow |
|
| `web/src/hooks/useFileUpload.ts` | Manages presigned URL upload flow |
|
||||||
@@ -421,7 +421,7 @@ Below the picker, the selected category is shown as a breadcrumb: `Fasteners ›
|
|||||||
|
|
||||||
### FileDropZone
|
### FileDropZone
|
||||||
|
|
||||||
Handles drag-and-drop and click-to-browse file uploads with MinIO presigned URL flow.
|
Handles drag-and-drop and click-to-browse file uploads.
|
||||||
|
|
||||||
**Props**:
|
**Props**:
|
||||||
|
|
||||||
@@ -435,7 +435,7 @@ interface FileDropZoneProps {
|
|||||||
|
|
||||||
interface PendingAttachment {
|
interface PendingAttachment {
|
||||||
file: File;
|
file: File;
|
||||||
objectKey: string; // MinIO key after upload
|
objectKey: string; // storage key after upload
|
||||||
uploadProgress: number; // 0-100
|
uploadProgress: number; // 0-100
|
||||||
uploadStatus: 'pending' | 'uploading' | 'complete' | 'error';
|
uploadStatus: 'pending' | 'uploading' | 'complete' | 'error';
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -462,7 +462,7 @@ Clicking the zone opens a hidden `<input type="file" multiple>`.
|
|||||||
|
|
||||||
1. On file selection/drop, immediately request a presigned upload URL: `POST /api/uploads/presign` with `{ filename, content_type, size }`.
|
1. On file selection/drop, immediately request a presigned upload URL: `POST /api/uploads/presign` with `{ filename, content_type, size }`.
|
||||||
2. Backend returns `{ object_key, upload_url, expires_at }`.
|
2. Backend returns `{ object_key, upload_url, expires_at }`.
|
||||||
3. `PUT` the file directly to the presigned MinIO URL using `XMLHttpRequest` (for progress tracking).
|
3. `PUT` the file directly to the presigned URL using `XMLHttpRequest` (for progress tracking).
|
||||||
4. On completion, update `PendingAttachment.uploadStatus` to `'complete'` and store the `object_key`.
|
4. On completion, update `PendingAttachment.uploadStatus` to `'complete'` and store the `object_key`.
|
||||||
5. The `object_key` is later sent to the item creation endpoint to associate the file.
|
5. The `object_key` is later sent to the item creation endpoint to associate the file.
|
||||||
|
|
||||||
@@ -589,10 +589,10 @@ Items 1-5 below are implemented. Item 4 (hierarchical categories) is resolved by
|
|||||||
```
|
```
|
||||||
POST /api/uploads/presign
|
POST /api/uploads/presign
|
||||||
Request: { "filename": "bracket.FCStd", "content_type": "application/octet-stream", "size": 2400000 }
|
Request: { "filename": "bracket.FCStd", "content_type": "application/octet-stream", "size": 2400000 }
|
||||||
Response: { "object_key": "uploads/tmp/{uuid}/{filename}", "upload_url": "https://minio.../...", "expires_at": "2026-02-06T..." }
|
Response: { "object_key": "uploads/tmp/{uuid}/{filename}", "upload_url": "https://...", "expires_at": "2026-02-06T..." }
|
||||||
```
|
```
|
||||||
|
|
||||||
The Go handler generates a presigned PUT URL via the MinIO SDK. Objects are uploaded to a temporary prefix. On item creation, they're moved/linked to the item's permanent prefix.
|
The Go handler generates a presigned PUT URL for direct upload. Objects are uploaded to a temporary prefix. On item creation, they're moved/linked to the item's permanent prefix.
|
||||||
|
|
||||||
### 2. File Association -- IMPLEMENTED
|
### 2. File Association -- IMPLEMENTED
|
||||||
|
|
||||||
@@ -612,7 +612,7 @@ Request: { "object_key": "uploads/tmp/{uuid}/thumb.png" }
|
|||||||
Response: 204
|
Response: 204
|
||||||
```
|
```
|
||||||
|
|
||||||
Stores the thumbnail at `items/{item_id}/thumbnail.png` in MinIO. Updates `item.thumbnail_key` column.
|
Stores the thumbnail at `items/{item_id}/thumbnail.png` in storage. Updates `item.thumbnail_key` column.
|
||||||
|
|
||||||
### 4. Hierarchical Categories -- IMPLEMENTED (via Form Descriptor)
|
### 4. Hierarchical Categories -- IMPLEMENTED (via Form Descriptor)
|
||||||
|
|
||||||
|
|||||||
13
go.mod
13
go.mod
@@ -11,7 +11,6 @@ require (
|
|||||||
github.com/go-ldap/ldap/v3 v3.4.12
|
github.com/go-ldap/ldap/v3 v3.4.12
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.5.4
|
github.com/jackc/pgx/v5 v5.5.4
|
||||||
github.com/minio/minio-go/v7 v7.0.66
|
|
||||||
github.com/rs/zerolog v1.32.0
|
github.com/rs/zerolog v1.32.0
|
||||||
github.com/sahilm/fuzzy v0.1.1
|
github.com/sahilm/fuzzy v0.1.1
|
||||||
golang.org/x/crypto v0.47.0
|
golang.org/x/crypto v0.47.0
|
||||||
@@ -21,28 +20,16 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
|
||||||
github.com/klauspost/compress v1.17.4 // indirect
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
|
||||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
github.com/minio/md5-simd v1.1.2 // indirect
|
|
||||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/rs/xid v1.5.0 // indirect
|
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
|
||||||
golang.org/x/net v0.48.0 // indirect
|
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
27
go.sum
27
go.sum
@@ -13,8 +13,6 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||||
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
|
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
|
||||||
@@ -26,7 +24,6 @@ github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9
|
|||||||
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
||||||
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
||||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||||
@@ -51,13 +48,6 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
|
|||||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
|
||||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
|
||||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
@@ -73,31 +63,17 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
|||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
|
||||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
|
||||||
github.com/minio/minio-go/v7 v7.0.66 h1:bnTOXOHjOqv/gcMuiVbN9o2ngRItvqE774dG9nq0Dzw=
|
|
||||||
github.com/minio/minio-go/v7 v7.0.66/go.mod h1:DHAgmyQEGdW3Cif0UooKOyrT3Vxs82zNdV6tkKhRtbs=
|
|
||||||
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
|
|
||||||
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
|
|
||||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||||
github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
|
github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
|
||||||
github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||||
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -133,7 +109,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
|
|||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -166,8 +141,6 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
|
|||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
|
||||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
391
internal/api/approval_handlers.go
Normal file
391
internal/api/approval_handlers.go
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
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"
|
||||||
|
"github.com/kindredsystems/silo/internal/workflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApprovalResponse is the JSON representation for approval endpoints.
|
||||||
|
type ApprovalResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
WorkflowName string `json:"workflow"`
|
||||||
|
ECONumber *string `json:"eco_number"`
|
||||||
|
State string `json:"state"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
UpdatedBy *string `json:"updated_by"`
|
||||||
|
Signatures []SignatureResponse `json:"signatures"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignatureResponse is the JSON representation for a signature.
|
||||||
|
type SignatureResponse struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
SignedAt *string `json:"signed_at"`
|
||||||
|
Comment *string `json:"comment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateApprovalRequest is the JSON body for POST /approvals.
|
||||||
|
type CreateApprovalRequest struct {
|
||||||
|
Workflow string `json:"workflow"`
|
||||||
|
ECONumber string `json:"eco_number"`
|
||||||
|
Signers []SignerRequest `json:"signers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignerRequest defines a signer in the create request.
|
||||||
|
type SignerRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignApprovalRequest is the JSON body for POST /approvals/{id}/sign.
|
||||||
|
type SignApprovalRequest struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Comment *string `json:"comment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func approvalToResponse(a *db.ItemApproval) ApprovalResponse {
|
||||||
|
sigs := make([]SignatureResponse, len(a.Signatures))
|
||||||
|
for i, s := range a.Signatures {
|
||||||
|
var signedAt *string
|
||||||
|
if s.SignedAt != nil {
|
||||||
|
t := s.SignedAt.UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
signedAt = &t
|
||||||
|
}
|
||||||
|
sigs[i] = SignatureResponse{
|
||||||
|
Username: s.Username,
|
||||||
|
Role: s.Role,
|
||||||
|
Status: s.Status,
|
||||||
|
SignedAt: signedAt,
|
||||||
|
Comment: s.Comment,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ApprovalResponse{
|
||||||
|
ID: a.ID,
|
||||||
|
WorkflowName: a.WorkflowName,
|
||||||
|
ECONumber: a.ECONumber,
|
||||||
|
State: a.State,
|
||||||
|
UpdatedAt: a.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedBy: a.UpdatedBy,
|
||||||
|
Signatures: sigs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleGetApprovals returns all approvals with signatures for an item.
|
||||||
|
// GET /api/items/{partNumber}/approvals
|
||||||
|
func (s *Server) HandleGetApprovals(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
|
||||||
|
}
|
||||||
|
|
||||||
|
approvals, err := s.approvals.ListByItemWithSignatures(ctx, item.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to list approvals")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list approvals")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := make([]ApprovalResponse, len(approvals))
|
||||||
|
for i, a := range approvals {
|
||||||
|
resp[i] = approvalToResponse(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleCreateApproval creates an ECO with a workflow and signers.
|
||||||
|
// POST /api/items/{partNumber}/approvals
|
||||||
|
func (s *Server) HandleCreateApproval(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
|
||||||
|
}
|
||||||
|
|
||||||
|
var req CreateApprovalRequest
|
||||||
|
if err := readJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Signers) == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_body", "At least one signer is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate workflow exists
|
||||||
|
wf, ok := s.workflows[req.Workflow]
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_workflow", "Workflow '"+req.Workflow+"' not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each signer's role matches a gate in the workflow
|
||||||
|
for _, signer := range req.Signers {
|
||||||
|
if !wf.HasRole(signer.Role) {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_role",
|
||||||
|
"Role '"+signer.Role+"' is not defined in workflow '"+req.Workflow+"'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all required gates have at least one signer
|
||||||
|
signerRoles := make(map[string]bool)
|
||||||
|
for _, signer := range req.Signers {
|
||||||
|
signerRoles[signer.Role] = true
|
||||||
|
}
|
||||||
|
for _, gate := range wf.RequiredGates() {
|
||||||
|
if !signerRoles[gate.Role] {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing_required_signer",
|
||||||
|
"Required role '"+gate.Role+"' ("+gate.Label+") has no assigned signer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
username := ""
|
||||||
|
if user := auth.UserFromContext(ctx); user != nil {
|
||||||
|
username = user.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
var ecoNumber *string
|
||||||
|
if req.ECONumber != "" {
|
||||||
|
ecoNumber = &req.ECONumber
|
||||||
|
}
|
||||||
|
|
||||||
|
approval := &db.ItemApproval{
|
||||||
|
ItemID: item.ID,
|
||||||
|
WorkflowName: req.Workflow,
|
||||||
|
ECONumber: ecoNumber,
|
||||||
|
State: "pending",
|
||||||
|
UpdatedBy: &username,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.approvals.Create(ctx, approval); err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to create approval")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create approval")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add signature rows for each signer
|
||||||
|
for _, signer := range req.Signers {
|
||||||
|
sig := &db.ApprovalSignature{
|
||||||
|
ApprovalID: approval.ID,
|
||||||
|
Username: signer.Username,
|
||||||
|
Role: signer.Role,
|
||||||
|
Status: "pending",
|
||||||
|
}
|
||||||
|
if err := s.approvals.AddSignature(ctx, sig); err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to add signature")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to add signer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch with signatures for response
|
||||||
|
approval, err = s.approvals.GetWithSignatures(ctx, approval.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get approval")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get approval")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := approvalToResponse(approval)
|
||||||
|
writeJSON(w, http.StatusCreated, resp)
|
||||||
|
s.broker.Publish("approval.created", mustMarshal(map[string]any{
|
||||||
|
"part_number": partNumber,
|
||||||
|
"approval_id": approval.ID,
|
||||||
|
"workflow": approval.WorkflowName,
|
||||||
|
"eco_number": approval.ECONumber,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSignApproval records an approve or reject signature.
|
||||||
|
// POST /api/items/{partNumber}/approvals/{id}/sign
|
||||||
|
func (s *Server) HandleSignApproval(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
partNumber := chi.URLParam(r, "partNumber")
|
||||||
|
approvalID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
approval, err := s.approvals.GetWithSignatures(ctx, approvalID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get approval")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get approval")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if approval == nil || approval.ItemID != item.ID {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Approval not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if approval.State != "pending" {
|
||||||
|
writeError(w, http.StatusUnprocessableEntity, "invalid_state",
|
||||||
|
"Approval is in state '"+approval.State+"', signatures can only be added when 'pending'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req SignApprovalRequest
|
||||||
|
if err := readJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Status != "approved" && req.Status != "rejected" {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_status", "Status must be 'approved' or 'rejected'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the caller's username
|
||||||
|
username := ""
|
||||||
|
if user := auth.UserFromContext(ctx); user != nil {
|
||||||
|
username = user.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that the caller has a pending signature on this approval
|
||||||
|
sig, err := s.approvals.GetSignatureForUser(ctx, approvalID, username)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get signature")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check signature")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sig == nil {
|
||||||
|
writeError(w, http.StatusForbidden, "not_a_signer", "You are not a signer on this approval")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sig.Status != "pending" {
|
||||||
|
writeError(w, http.StatusConflict, "already_signed", "You have already signed this approval")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the signature
|
||||||
|
if err := s.approvals.UpdateSignature(ctx, sig.ID, req.Status, req.Comment); err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to update signature")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update signature")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.broker.Publish("approval.signed", mustMarshal(map[string]any{
|
||||||
|
"part_number": partNumber,
|
||||||
|
"approval_id": approvalID,
|
||||||
|
"username": username,
|
||||||
|
"status": req.Status,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Evaluate auto-advance based on workflow rules
|
||||||
|
wf := s.workflows[approval.WorkflowName]
|
||||||
|
if wf != nil {
|
||||||
|
// Re-fetch signatures after update
|
||||||
|
approval, err = s.approvals.GetWithSignatures(ctx, approvalID)
|
||||||
|
if err == nil && approval != nil {
|
||||||
|
newState := evaluateApprovalState(wf, approval)
|
||||||
|
if newState != "" && newState != approval.State {
|
||||||
|
if err := s.approvals.UpdateState(ctx, approvalID, newState, username); err != nil {
|
||||||
|
s.logger.Warn().Err(err).Msg("failed to auto-advance approval state")
|
||||||
|
} else {
|
||||||
|
approval.State = newState
|
||||||
|
s.broker.Publish("approval.completed", mustMarshal(map[string]any{
|
||||||
|
"part_number": partNumber,
|
||||||
|
"approval_id": approvalID,
|
||||||
|
"state": newState,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return updated approval
|
||||||
|
if approval == nil {
|
||||||
|
approval, _ = s.approvals.GetWithSignatures(ctx, approvalID)
|
||||||
|
}
|
||||||
|
if approval != nil {
|
||||||
|
writeJSON(w, http.StatusOK, approvalToResponse(approval))
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleListWorkflows returns all loaded workflow definitions.
|
||||||
|
// GET /api/workflows
|
||||||
|
func (s *Server) HandleListWorkflows(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp := make([]map[string]any, 0, len(s.workflows))
|
||||||
|
for _, wf := range s.workflows {
|
||||||
|
resp = append(resp, map[string]any{
|
||||||
|
"name": wf.Name,
|
||||||
|
"version": wf.Version,
|
||||||
|
"description": wf.Description,
|
||||||
|
"gates": wf.Gates,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluateApprovalState checks workflow rules against current signatures
|
||||||
|
// and returns the new state, or "" if no transition is needed.
|
||||||
|
func evaluateApprovalState(wf *workflow.Workflow, approval *db.ItemApproval) string {
|
||||||
|
// Check for any rejection
|
||||||
|
if wf.Rules.AnyReject != "" {
|
||||||
|
for _, sig := range approval.Signatures {
|
||||||
|
if sig.Status == "rejected" {
|
||||||
|
return wf.Rules.AnyReject
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all required roles have approved
|
||||||
|
if wf.Rules.AllRequiredApprove != "" {
|
||||||
|
requiredRoles := make(map[string]bool)
|
||||||
|
for _, gate := range wf.RequiredGates() {
|
||||||
|
requiredRoles[gate.Role] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each required role, check that all signers with that role have approved
|
||||||
|
for _, sig := range approval.Signatures {
|
||||||
|
if requiredRoles[sig.Role] && sig.Status != "approved" {
|
||||||
|
return "" // at least one required signer hasn't approved yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All required signers approved
|
||||||
|
return wf.Rules.AllRequiredApprove
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// readJSON decodes a JSON request body.
|
||||||
|
func readJSON(r *http.Request, v any) error {
|
||||||
|
return json.NewDecoder(r.Body).Decode(v)
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ func newAuthTestServer(t *testing.T) *Server {
|
|||||||
"", // jobDefsDir
|
"", // jobDefsDir
|
||||||
modules.NewRegistry(), // modules
|
modules.NewRegistry(), // modules
|
||||||
nil, // cfg
|
nil, // cfg
|
||||||
|
nil, // workflows
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ func newTestServer(t *testing.T) *Server {
|
|||||||
"", // jobDefsDir
|
"", // jobDefsDir
|
||||||
modules.NewRegistry(), // modules
|
modules.NewRegistry(), // modules
|
||||||
nil, // cfg
|
nil, // cfg
|
||||||
|
nil, // workflows
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,34 @@ type Event struct {
|
|||||||
|
|
||||||
// sseClient represents a single connected SSE consumer.
|
// sseClient represents a single connected SSE consumer.
|
||||||
type sseClient struct {
|
type sseClient struct {
|
||||||
ch chan Event
|
ch chan Event
|
||||||
closed chan struct{}
|
closed chan struct{}
|
||||||
|
userID string
|
||||||
|
workstationID string
|
||||||
|
mu sync.RWMutex
|
||||||
|
itemFilters map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchItem adds an item ID to this client's filter set.
|
||||||
|
func (c *sseClient) WatchItem(itemID string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.itemFilters[itemID] = struct{}{}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnwatchItem removes an item ID from this client's filter set.
|
||||||
|
func (c *sseClient) UnwatchItem(itemID string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.itemFilters, itemID)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsWatchingItem returns whether this client is watching a specific item.
|
||||||
|
func (c *sseClient) IsWatchingItem(itemID string) bool {
|
||||||
|
c.mu.RLock()
|
||||||
|
_, ok := c.itemFilters[itemID]
|
||||||
|
c.mu.RUnlock()
|
||||||
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -52,10 +78,13 @@ func NewBroker(logger zerolog.Logger) *Broker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe adds a new client and returns it. The caller must call Unsubscribe when done.
|
// Subscribe adds a new client and returns it. The caller must call Unsubscribe when done.
|
||||||
func (b *Broker) Subscribe() *sseClient {
|
func (b *Broker) Subscribe(userID, workstationID string) *sseClient {
|
||||||
c := &sseClient{
|
c := &sseClient{
|
||||||
ch: make(chan Event, clientChanSize),
|
ch: make(chan Event, clientChanSize),
|
||||||
closed: make(chan struct{}),
|
closed: make(chan struct{}),
|
||||||
|
userID: userID,
|
||||||
|
workstationID: workstationID,
|
||||||
|
itemFilters: make(map[string]struct{}),
|
||||||
}
|
}
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
b.clients[c] = struct{}{}
|
b.clients[c] = struct{}{}
|
||||||
@@ -106,6 +135,49 @@ func (b *Broker) Publish(eventType string, data string) {
|
|||||||
b.mu.RUnlock()
|
b.mu.RUnlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// publishTargeted sends an event only to clients matching the predicate.
|
||||||
|
// Targeted events get an ID but are not stored in the history ring buffer.
|
||||||
|
func (b *Broker) publishTargeted(eventType, data string, match func(*sseClient) bool) {
|
||||||
|
ev := Event{
|
||||||
|
ID: b.eventID.Add(1),
|
||||||
|
Type: eventType,
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.RLock()
|
||||||
|
for c := range b.clients {
|
||||||
|
if match(c) {
|
||||||
|
select {
|
||||||
|
case c.ch <- ev:
|
||||||
|
default:
|
||||||
|
b.logger.Warn().Uint64("event_id", ev.ID).Str("type", eventType).Msg("dropped targeted event for slow client")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishToItem sends an event only to clients watching a specific item.
|
||||||
|
func (b *Broker) PublishToItem(itemID, eventType, data string) {
|
||||||
|
b.publishTargeted(eventType, data, func(c *sseClient) bool {
|
||||||
|
return c.IsWatchingItem(itemID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishToWorkstation sends an event only to the specified workstation.
|
||||||
|
func (b *Broker) PublishToWorkstation(workstationID, eventType, data string) {
|
||||||
|
b.publishTargeted(eventType, data, func(c *sseClient) bool {
|
||||||
|
return c.workstationID == workstationID
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishToUser sends an event to all connections for a specific user.
|
||||||
|
func (b *Broker) PublishToUser(userID, eventType, data string) {
|
||||||
|
b.publishTargeted(eventType, data, func(c *sseClient) bool {
|
||||||
|
return c.userID == userID
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ClientCount returns the number of connected SSE clients.
|
// ClientCount returns the number of connected SSE clients.
|
||||||
func (b *Broker) ClientCount() int {
|
func (b *Broker) ClientCount() int {
|
||||||
b.mu.RLock()
|
b.mu.RLock()
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
func TestBrokerSubscribeUnsubscribe(t *testing.T) {
|
func TestBrokerSubscribeUnsubscribe(t *testing.T) {
|
||||||
b := NewBroker(zerolog.Nop())
|
b := NewBroker(zerolog.Nop())
|
||||||
|
|
||||||
c := b.Subscribe()
|
c := b.Subscribe("", "")
|
||||||
if b.ClientCount() != 1 {
|
if b.ClientCount() != 1 {
|
||||||
t.Fatalf("expected 1 client, got %d", b.ClientCount())
|
t.Fatalf("expected 1 client, got %d", b.ClientCount())
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@ func TestBrokerSubscribeUnsubscribe(t *testing.T) {
|
|||||||
|
|
||||||
func TestBrokerPublish(t *testing.T) {
|
func TestBrokerPublish(t *testing.T) {
|
||||||
b := NewBroker(zerolog.Nop())
|
b := NewBroker(zerolog.Nop())
|
||||||
c := b.Subscribe()
|
c := b.Subscribe("", "")
|
||||||
defer b.Unsubscribe(c)
|
defer b.Unsubscribe(c)
|
||||||
|
|
||||||
b.Publish("item.created", `{"part_number":"F01-0001"}`)
|
b.Publish("item.created", `{"part_number":"F01-0001"}`)
|
||||||
@@ -46,7 +46,7 @@ func TestBrokerPublish(t *testing.T) {
|
|||||||
|
|
||||||
func TestBrokerPublishDropsSlow(t *testing.T) {
|
func TestBrokerPublishDropsSlow(t *testing.T) {
|
||||||
b := NewBroker(zerolog.Nop())
|
b := NewBroker(zerolog.Nop())
|
||||||
c := b.Subscribe()
|
c := b.Subscribe("", "")
|
||||||
defer b.Unsubscribe(c)
|
defer b.Unsubscribe(c)
|
||||||
|
|
||||||
// Fill the client's channel
|
// Fill the client's channel
|
||||||
@@ -89,9 +89,9 @@ func TestBrokerEventsSince(t *testing.T) {
|
|||||||
func TestBrokerClientCount(t *testing.T) {
|
func TestBrokerClientCount(t *testing.T) {
|
||||||
b := NewBroker(zerolog.Nop())
|
b := NewBroker(zerolog.Nop())
|
||||||
|
|
||||||
c1 := b.Subscribe()
|
c1 := b.Subscribe("", "")
|
||||||
c2 := b.Subscribe()
|
c2 := b.Subscribe("", "")
|
||||||
c3 := b.Subscribe()
|
c3 := b.Subscribe("", "")
|
||||||
|
|
||||||
if b.ClientCount() != 3 {
|
if b.ClientCount() != 3 {
|
||||||
t.Fatalf("expected 3 clients, got %d", b.ClientCount())
|
t.Fatalf("expected 3 clients, got %d", b.ClientCount())
|
||||||
@@ -111,7 +111,7 @@ func TestBrokerClientCount(t *testing.T) {
|
|||||||
|
|
||||||
func TestBrokerShutdown(t *testing.T) {
|
func TestBrokerShutdown(t *testing.T) {
|
||||||
b := NewBroker(zerolog.Nop())
|
b := NewBroker(zerolog.Nop())
|
||||||
c := b.Subscribe()
|
c := b.Subscribe("", "")
|
||||||
|
|
||||||
b.Shutdown()
|
b.Shutdown()
|
||||||
|
|
||||||
@@ -145,3 +145,128 @@ func TestBrokerMonotonicIDs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWatchUnwatchItem(t *testing.T) {
|
||||||
|
b := NewBroker(zerolog.Nop())
|
||||||
|
c := b.Subscribe("user1", "ws1")
|
||||||
|
defer b.Unsubscribe(c)
|
||||||
|
|
||||||
|
if c.IsWatchingItem("item-abc") {
|
||||||
|
t.Fatal("should not be watching item-abc before WatchItem")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.WatchItem("item-abc")
|
||||||
|
if !c.IsWatchingItem("item-abc") {
|
||||||
|
t.Fatal("should be watching item-abc after WatchItem")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.UnwatchItem("item-abc")
|
||||||
|
if c.IsWatchingItem("item-abc") {
|
||||||
|
t.Fatal("should not be watching item-abc after UnwatchItem")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishToItem(t *testing.T) {
|
||||||
|
b := NewBroker(zerolog.Nop())
|
||||||
|
watcher := b.Subscribe("user1", "ws1")
|
||||||
|
defer b.Unsubscribe(watcher)
|
||||||
|
bystander := b.Subscribe("user2", "ws2")
|
||||||
|
defer b.Unsubscribe(bystander)
|
||||||
|
|
||||||
|
watcher.WatchItem("item-abc")
|
||||||
|
b.PublishToItem("item-abc", "edit.started", `{"item_id":"item-abc"}`)
|
||||||
|
|
||||||
|
// Watcher should receive the event.
|
||||||
|
select {
|
||||||
|
case ev := <-watcher.ch:
|
||||||
|
if ev.Type != "edit.started" {
|
||||||
|
t.Fatalf("expected edit.started, got %s", ev.Type)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("watcher did not receive targeted event")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bystander should not.
|
||||||
|
select {
|
||||||
|
case ev := <-bystander.ch:
|
||||||
|
t.Fatalf("bystander should not receive targeted event, got %s", ev.Type)
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
// expected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishToWorkstation(t *testing.T) {
|
||||||
|
b := NewBroker(zerolog.Nop())
|
||||||
|
target := b.Subscribe("user1", "ws-target")
|
||||||
|
defer b.Unsubscribe(target)
|
||||||
|
other := b.Subscribe("user1", "ws-other")
|
||||||
|
defer b.Unsubscribe(other)
|
||||||
|
|
||||||
|
b.PublishToWorkstation("ws-target", "sync.update", `{"data":"x"}`)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case ev := <-target.ch:
|
||||||
|
if ev.Type != "sync.update" {
|
||||||
|
t.Fatalf("expected sync.update, got %s", ev.Type)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("target workstation did not receive event")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case ev := <-other.ch:
|
||||||
|
t.Fatalf("other workstation should not receive event, got %s", ev.Type)
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
// expected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishToUser(t *testing.T) {
|
||||||
|
b := NewBroker(zerolog.Nop())
|
||||||
|
c1 := b.Subscribe("user1", "ws1")
|
||||||
|
defer b.Unsubscribe(c1)
|
||||||
|
c2 := b.Subscribe("user1", "ws2")
|
||||||
|
defer b.Unsubscribe(c2)
|
||||||
|
c3 := b.Subscribe("user2", "ws3")
|
||||||
|
defer b.Unsubscribe(c3)
|
||||||
|
|
||||||
|
b.PublishToUser("user1", "user.notify", `{"msg":"hello"}`)
|
||||||
|
|
||||||
|
// Both user1 connections should receive.
|
||||||
|
for _, c := range []*sseClient{c1, c2} {
|
||||||
|
select {
|
||||||
|
case ev := <-c.ch:
|
||||||
|
if ev.Type != "user.notify" {
|
||||||
|
t.Fatalf("expected user.notify, got %s", ev.Type)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("user1 client did not receive event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// user2 should not.
|
||||||
|
select {
|
||||||
|
case ev := <-c3.ch:
|
||||||
|
t.Fatalf("user2 should not receive event, got %s", ev.Type)
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
// expected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTargetedEventsNotInHistory(t *testing.T) {
|
||||||
|
b := NewBroker(zerolog.Nop())
|
||||||
|
c := b.Subscribe("user1", "ws1")
|
||||||
|
defer b.Unsubscribe(c)
|
||||||
|
c.WatchItem("item-abc")
|
||||||
|
|
||||||
|
b.Publish("broadcast", `{}`)
|
||||||
|
b.PublishToItem("item-abc", "targeted", `{}`)
|
||||||
|
|
||||||
|
events := b.EventsSince(0)
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("expected 1 event in history (broadcast only), got %d", len(events))
|
||||||
|
}
|
||||||
|
if events[0].Type != "broadcast" {
|
||||||
|
t.Fatalf("expected broadcast event in history, got %s", events[0].Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ func newTestServerWithSchemas(t *testing.T) *Server {
|
|||||||
"", // jobDefsDir
|
"", // jobDefsDir
|
||||||
modules.NewRegistry(), // modules
|
modules.NewRegistry(), // modules
|
||||||
nil, // cfg
|
nil, // cfg
|
||||||
|
nil, // workflows
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ func newDAGTestServer(t *testing.T) *Server {
|
|||||||
broker, state,
|
broker, state,
|
||||||
nil, "",
|
nil, "",
|
||||||
modules.NewRegistry(), nil,
|
modules.NewRegistry(), nil,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type presignUploadRequest struct {
|
|||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandlePresignUpload generates a presigned PUT URL for direct browser upload to MinIO.
|
// HandlePresignUpload generates a presigned PUT URL for direct browser upload.
|
||||||
func (s *Server) HandlePresignUpload(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) HandlePresignUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.storage == nil {
|
if s.storage == nil {
|
||||||
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured")
|
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured")
|
||||||
@@ -317,12 +317,9 @@ func (s *Server) HandleSetItemThumbnail(w http.ResponseWriter, r *http.Request)
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// storageBackend returns the configured storage backend name, defaulting to "minio".
|
// storageBackend returns the configured storage backend name.
|
||||||
func (s *Server) storageBackend() string {
|
func (s *Server) storageBackend() string {
|
||||||
if s.cfg != nil && s.cfg.Storage.Backend != "" {
|
return "filesystem"
|
||||||
return s.cfg.Storage.Backend
|
|
||||||
}
|
|
||||||
return "minio"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleUploadItemFile accepts a multipart file upload and stores it as an item attachment.
|
// HandleUploadItemFile accepts a multipart file upload and stores it as an item attachment.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/kindredsystems/silo/internal/partnum"
|
"github.com/kindredsystems/silo/internal/partnum"
|
||||||
"github.com/kindredsystems/silo/internal/schema"
|
"github.com/kindredsystems/silo/internal/schema"
|
||||||
"github.com/kindredsystems/silo/internal/storage"
|
"github.com/kindredsystems/silo/internal/storage"
|
||||||
|
"github.com/kindredsystems/silo/internal/workflow"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
@@ -57,6 +58,11 @@ type Server struct {
|
|||||||
settings *db.SettingsRepository
|
settings *db.SettingsRepository
|
||||||
metadata *db.ItemMetadataRepository
|
metadata *db.ItemMetadataRepository
|
||||||
deps *db.ItemDependencyRepository
|
deps *db.ItemDependencyRepository
|
||||||
|
macros *db.ItemMacroRepository
|
||||||
|
approvals *db.ItemApprovalRepository
|
||||||
|
workflows map[string]*workflow.Workflow
|
||||||
|
solverResults *db.SolverResultRepository
|
||||||
|
workstations *db.WorkstationRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new API server.
|
// NewServer creates a new API server.
|
||||||
@@ -76,6 +82,7 @@ func NewServer(
|
|||||||
jobDefsDir string,
|
jobDefsDir string,
|
||||||
registry *modules.Registry,
|
registry *modules.Registry,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
|
workflows map[string]*workflow.Workflow,
|
||||||
) *Server {
|
) *Server {
|
||||||
items := db.NewItemRepository(database)
|
items := db.NewItemRepository(database)
|
||||||
projects := db.NewProjectRepository(database)
|
projects := db.NewProjectRepository(database)
|
||||||
@@ -87,6 +94,10 @@ func NewServer(
|
|||||||
locations := db.NewLocationRepository(database)
|
locations := db.NewLocationRepository(database)
|
||||||
metadata := db.NewItemMetadataRepository(database)
|
metadata := db.NewItemMetadataRepository(database)
|
||||||
itemDeps := db.NewItemDependencyRepository(database)
|
itemDeps := db.NewItemDependencyRepository(database)
|
||||||
|
itemMacros := db.NewItemMacroRepository(database)
|
||||||
|
itemApprovals := db.NewItemApprovalRepository(database)
|
||||||
|
solverResults := db.NewSolverResultRepository(database)
|
||||||
|
workstations := db.NewWorkstationRepository(database)
|
||||||
seqStore := &dbSequenceStore{db: database, schemas: schemas}
|
seqStore := &dbSequenceStore{db: database, schemas: schemas}
|
||||||
partgen := partnum.NewGenerator(schemas, seqStore)
|
partgen := partnum.NewGenerator(schemas, seqStore)
|
||||||
|
|
||||||
@@ -117,6 +128,11 @@ func NewServer(
|
|||||||
settings: settings,
|
settings: settings,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
deps: itemDeps,
|
deps: itemDeps,
|
||||||
|
macros: itemMacros,
|
||||||
|
approvals: itemApprovals,
|
||||||
|
workflows: workflows,
|
||||||
|
solverResults: solverResults,
|
||||||
|
workstations: workstations,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ func newJobTestServer(t *testing.T) *Server {
|
|||||||
broker, state,
|
broker, state,
|
||||||
nil, "",
|
nil, "",
|
||||||
modules.NewRegistry(), nil,
|
modules.NewRegistry(), nil,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
95
internal/api/macro_handlers.go
Normal file
95
internal/api/macro_handlers.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MacroListItem is the JSON representation for GET /macros list entries.
|
||||||
|
type MacroListItem struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
RevisionNumber int `json:"revision_number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MacroResponse is the JSON representation for GET /macros/{filename}.
|
||||||
|
type MacroResponse struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
RevisionNumber int `json:"revision_number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleGetMacros returns the list of registered macros for an item.
|
||||||
|
// GET /api/items/{partNumber}/macros
|
||||||
|
func (s *Server) HandleGetMacros(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
|
||||||
|
}
|
||||||
|
|
||||||
|
macros, err := s.macros.ListByItem(ctx, item.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to list macros")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list macros")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := make([]MacroListItem, len(macros))
|
||||||
|
for i, m := range macros {
|
||||||
|
resp[i] = MacroListItem{
|
||||||
|
Filename: m.Filename,
|
||||||
|
Trigger: m.Trigger,
|
||||||
|
RevisionNumber: m.RevisionNumber,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleGetMacro returns a single macro's source content.
|
||||||
|
// GET /api/items/{partNumber}/macros/{filename}
|
||||||
|
func (s *Server) HandleGetMacro(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
partNumber := chi.URLParam(r, "partNumber")
|
||||||
|
filename := chi.URLParam(r, "filename")
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
macro, err := s.macros.GetByFilename(ctx, item.ID, filename)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get macro")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get macro")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if macro == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Macro not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, MacroResponse{
|
||||||
|
Filename: macro.Filename,
|
||||||
|
Trigger: macro.Trigger,
|
||||||
|
Content: macro.Content,
|
||||||
|
RevisionNumber: macro.RevisionNumber,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -430,6 +430,27 @@ func (s *Server) extractKCMetadata(ctx context.Context, item *db.Item, fileKey s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Index macros from silo/macros/*.
|
||||||
|
if len(result.Macros) > 0 {
|
||||||
|
dbMacros := make([]*db.ItemMacro, len(result.Macros))
|
||||||
|
for i, m := range result.Macros {
|
||||||
|
dbMacros[i] = &db.ItemMacro{
|
||||||
|
ItemID: item.ID,
|
||||||
|
Filename: m.Filename,
|
||||||
|
Trigger: "manual",
|
||||||
|
Content: m.Content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.macros.ReplaceForItem(ctx, item.ID, rev.RevisionNumber, dbMacros); err != nil {
|
||||||
|
s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: failed to index macros")
|
||||||
|
} else {
|
||||||
|
s.broker.Publish("macros.changed", mustMarshal(map[string]any{
|
||||||
|
"part_number": item.PartNumber,
|
||||||
|
"count": len(dbMacros),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
s.logger.Info().Str("part_number", item.PartNumber).Msg("kc: metadata indexed successfully")
|
s.logger.Info().Str("part_number", item.PartNumber).Msg("kc: metadata indexed successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,11 +74,58 @@ func (s *Server) packKCFile(ctx context.Context, data []byte, item *db.Item, rev
|
|||||||
deps = []kc.Dependency{}
|
deps = []kc.Dependency{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build approvals from item_approvals table.
|
||||||
|
var approvals []kc.ApprovalEntry
|
||||||
|
dbApprovals, err := s.approvals.ListByItemWithSignatures(ctx, item.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: failed to query approvals for packing")
|
||||||
|
} else {
|
||||||
|
approvals = make([]kc.ApprovalEntry, len(dbApprovals))
|
||||||
|
for i, a := range dbApprovals {
|
||||||
|
sigs := make([]kc.SignatureEntry, len(a.Signatures))
|
||||||
|
for j, sig := range a.Signatures {
|
||||||
|
var signedAt string
|
||||||
|
if sig.SignedAt != nil {
|
||||||
|
signedAt = sig.SignedAt.UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
}
|
||||||
|
var comment string
|
||||||
|
if sig.Comment != nil {
|
||||||
|
comment = *sig.Comment
|
||||||
|
}
|
||||||
|
sigs[j] = kc.SignatureEntry{
|
||||||
|
Username: sig.Username,
|
||||||
|
Role: sig.Role,
|
||||||
|
Status: sig.Status,
|
||||||
|
SignedAt: signedAt,
|
||||||
|
Comment: comment,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var ecoNumber string
|
||||||
|
if a.ECONumber != nil {
|
||||||
|
ecoNumber = *a.ECONumber
|
||||||
|
}
|
||||||
|
var updatedBy string
|
||||||
|
if a.UpdatedBy != nil {
|
||||||
|
updatedBy = *a.UpdatedBy
|
||||||
|
}
|
||||||
|
approvals[i] = kc.ApprovalEntry{
|
||||||
|
ID: a.ID,
|
||||||
|
WorkflowName: a.WorkflowName,
|
||||||
|
ECONumber: ecoNumber,
|
||||||
|
State: a.State,
|
||||||
|
UpdatedAt: a.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedBy: updatedBy,
|
||||||
|
Signatures: sigs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
input := &kc.PackInput{
|
input := &kc.PackInput{
|
||||||
Manifest: manifest,
|
Manifest: manifest,
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
History: history,
|
History: history,
|
||||||
Dependencies: deps,
|
Dependencies: deps,
|
||||||
|
Approvals: approvals,
|
||||||
}
|
}
|
||||||
|
|
||||||
return kc.Pack(data, input)
|
return kc.Pack(data, input)
|
||||||
|
|||||||
@@ -68,6 +68,17 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
|||||||
// SSE event stream (viewer+)
|
// SSE event stream (viewer+)
|
||||||
r.Get("/events", server.HandleEvents)
|
r.Get("/events", server.HandleEvents)
|
||||||
|
|
||||||
|
// 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
|
// 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) {
|
||||||
@@ -175,6 +186,10 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
|||||||
r.Get("/metadata", server.HandleGetMetadata)
|
r.Get("/metadata", server.HandleGetMetadata)
|
||||||
r.Get("/dependencies", server.HandleGetDependencies)
|
r.Get("/dependencies", server.HandleGetDependencies)
|
||||||
r.Get("/dependencies/resolve", server.HandleResolveDependencies)
|
r.Get("/dependencies/resolve", server.HandleResolveDependencies)
|
||||||
|
r.Get("/macros", server.HandleGetMacros)
|
||||||
|
r.Get("/macros/{filename}", server.HandleGetMacro)
|
||||||
|
r.Get("/approvals", server.HandleGetApprovals)
|
||||||
|
r.Get("/solver/results", server.HandleGetSolverResults)
|
||||||
|
|
||||||
// DAG (gated by dag module)
|
// DAG (gated by dag module)
|
||||||
r.Route("/dag", func(r chi.Router) {
|
r.Route("/dag", func(r chi.Router) {
|
||||||
@@ -215,6 +230,8 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
|||||||
r.Put("/metadata", server.HandleUpdateMetadata)
|
r.Put("/metadata", server.HandleUpdateMetadata)
|
||||||
r.Patch("/metadata/lifecycle", server.HandleUpdateLifecycle)
|
r.Patch("/metadata/lifecycle", server.HandleUpdateLifecycle)
|
||||||
r.Patch("/metadata/tags", server.HandleUpdateTags)
|
r.Patch("/metadata/tags", server.HandleUpdateTags)
|
||||||
|
r.Post("/approvals", server.HandleCreateApproval)
|
||||||
|
r.Post("/approvals/{id}/sign", server.HandleSignApproval)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -242,6 +259,21 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Solver (gated by solver module)
|
||||||
|
r.Route("/solver", func(r chi.Router) {
|
||||||
|
r.Use(server.RequireModule("solver"))
|
||||||
|
r.Get("/solvers", server.HandleGetSolverRegistry)
|
||||||
|
r.Get("/jobs", server.HandleListSolverJobs)
|
||||||
|
r.Get("/jobs/{jobID}", server.HandleGetSolverJob)
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(server.RequireWritable)
|
||||||
|
r.Use(server.RequireRole(auth.RoleEditor))
|
||||||
|
r.Post("/jobs", server.HandleSubmitSolverJob)
|
||||||
|
r.Post("/jobs/{jobID}/cancel", server.HandleCancelSolverJob)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Sheets (editor)
|
// Sheets (editor)
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(server.RequireWritable)
|
r.Use(server.RequireWritable)
|
||||||
|
|||||||
@@ -142,6 +142,9 @@ func (s *Server) HandleRunnerCompleteJob(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache solver results asynchronously (no-op for non-solver jobs).
|
||||||
|
go s.maybeCacheSolverResult(context.Background(), jobID)
|
||||||
|
|
||||||
s.broker.Publish("job.completed", mustMarshal(map[string]any{
|
s.broker.Publish("job.completed", mustMarshal(map[string]any{
|
||||||
"job_id": jobID,
|
"job_id": jobID,
|
||||||
"runner_id": runner.ID,
|
"runner_id": runner.ID,
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ func (ss *ServerState) ToggleReadOnly() {
|
|||||||
ss.SetReadOnly(!current)
|
ss.SetReadOnly(!current)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartStorageHealthCheck launches a periodic check of MinIO reachability.
|
// StartStorageHealthCheck launches a periodic check of storage reachability.
|
||||||
// Updates storageOK and broadcasts server.state on transitions.
|
// Updates storageOK and broadcasts server.state on transitions.
|
||||||
func (ss *ServerState) StartStorageHealthCheck() {
|
func (ss *ServerState) StartStorageHealthCheck() {
|
||||||
if ss.storage == nil {
|
if ss.storage == nil {
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func TestServerStateToggleReadOnly(t *testing.T) {
|
|||||||
|
|
||||||
func TestServerStateBroadcastsOnTransition(t *testing.T) {
|
func TestServerStateBroadcastsOnTransition(t *testing.T) {
|
||||||
b := NewBroker(zerolog.Nop())
|
b := NewBroker(zerolog.Nop())
|
||||||
c := b.Subscribe()
|
c := b.Subscribe("", "")
|
||||||
defer b.Unsubscribe(c)
|
defer b.Unsubscribe(c)
|
||||||
|
|
||||||
ss := NewServerState(zerolog.Nop(), nil, b)
|
ss := NewServerState(zerolog.Nop(), nil, b)
|
||||||
|
|||||||
@@ -224,10 +224,8 @@ func (s *Server) buildSchemasSettings() map[string]any {
|
|||||||
func (s *Server) buildStorageSettings(ctx context.Context) map[string]any {
|
func (s *Server) buildStorageSettings(ctx context.Context) map[string]any {
|
||||||
result := map[string]any{
|
result := map[string]any{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"endpoint": s.cfg.Storage.Endpoint,
|
"backend": "filesystem",
|
||||||
"bucket": s.cfg.Storage.Bucket,
|
"root_dir": s.cfg.Storage.Filesystem.RootDir,
|
||||||
"use_ssl": s.cfg.Storage.UseSSL,
|
|
||||||
"region": s.cfg.Storage.Region,
|
|
||||||
}
|
}
|
||||||
if s.storage != nil {
|
if s.storage != nil {
|
||||||
if err := s.storage.Ping(ctx); err != nil {
|
if err := s.storage.Ping(ctx); err != nil {
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ func newSettingsTestServer(t *testing.T) *Server {
|
|||||||
MaxConnections: 10,
|
MaxConnections: 10,
|
||||||
},
|
},
|
||||||
Storage: config.StorageConfig{
|
Storage: config.StorageConfig{
|
||||||
Endpoint: "minio:9000", Bucket: "silo", Region: "us-east-1",
|
Backend: "filesystem",
|
||||||
AccessKey: "minioadmin", SecretKey: "miniosecret",
|
Filesystem: config.FilesystemConfig{RootDir: "/tmp/silo-test"},
|
||||||
},
|
},
|
||||||
Schemas: config.SchemasConfig{Directory: "/etc/silo/schemas", Default: "kindred-rd"},
|
Schemas: config.SchemasConfig{Directory: "/etc/silo/schemas", Default: "kindred-rd"},
|
||||||
Auth: config.AuthConfig{
|
Auth: config.AuthConfig{
|
||||||
@@ -61,6 +61,7 @@ func newSettingsTestServer(t *testing.T) *Server {
|
|||||||
"", // jobDefsDir
|
"", // jobDefsDir
|
||||||
modules.NewRegistry(), // modules
|
modules.NewRegistry(), // modules
|
||||||
cfg,
|
cfg,
|
||||||
|
nil, // workflows
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
551
internal/api/solver_handlers.go
Normal file
551
internal/api/solver_handlers.go
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kindredsystems/silo/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleEvents serves the SSE event stream.
|
// HandleEvents serves the SSE event stream.
|
||||||
@@ -31,9 +33,19 @@ func (s *Server) HandleEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Connection", "keep-alive")
|
w.Header().Set("Connection", "keep-alive")
|
||||||
w.Header().Set("X-Accel-Buffering", "no") // nginx: disable proxy buffering
|
w.Header().Set("X-Accel-Buffering", "no") // nginx: disable proxy buffering
|
||||||
|
|
||||||
client := s.broker.Subscribe()
|
userID := ""
|
||||||
|
if user := auth.UserFromContext(r.Context()); user != nil {
|
||||||
|
userID = user.ID
|
||||||
|
}
|
||||||
|
wsID := r.URL.Query().Get("workstation_id")
|
||||||
|
|
||||||
|
client := s.broker.Subscribe(userID, wsID)
|
||||||
defer s.broker.Unsubscribe(client)
|
defer s.broker.Unsubscribe(client)
|
||||||
|
|
||||||
|
if wsID != "" {
|
||||||
|
s.workstations.Touch(r.Context(), wsID)
|
||||||
|
}
|
||||||
|
|
||||||
// Replay missed events if Last-Event-ID is present.
|
// Replay missed events if Last-Event-ID is present.
|
||||||
if lastIDStr := r.Header.Get("Last-Event-ID"); lastIDStr != "" {
|
if lastIDStr := r.Header.Get("Last-Event-ID"); lastIDStr != "" {
|
||||||
if lastID, err := strconv.ParseUint(lastIDStr, 10, 64); err == nil {
|
if lastID, err := strconv.ParseUint(lastIDStr, 10, 64); err == nil {
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
@@ -10,15 +10,17 @@ import (
|
|||||||
|
|
||||||
// Config holds all application configuration.
|
// Config holds all application configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `yaml:"server"`
|
Server ServerConfig `yaml:"server"`
|
||||||
Database DatabaseConfig `yaml:"database"`
|
Database DatabaseConfig `yaml:"database"`
|
||||||
Storage StorageConfig `yaml:"storage"`
|
Storage StorageConfig `yaml:"storage"`
|
||||||
Schemas SchemasConfig `yaml:"schemas"`
|
Schemas SchemasConfig `yaml:"schemas"`
|
||||||
FreeCAD FreeCADConfig `yaml:"freecad"`
|
FreeCAD FreeCADConfig `yaml:"freecad"`
|
||||||
Odoo OdooConfig `yaml:"odoo"`
|
Odoo OdooConfig `yaml:"odoo"`
|
||||||
Auth AuthConfig `yaml:"auth"`
|
Auth AuthConfig `yaml:"auth"`
|
||||||
Jobs JobsConfig `yaml:"jobs"`
|
Jobs JobsConfig `yaml:"jobs"`
|
||||||
Modules ModulesConfig `yaml:"modules"`
|
Workflows WorkflowsConfig `yaml:"workflows"`
|
||||||
|
Solver SolverConfig `yaml:"solver"`
|
||||||
|
Modules ModulesConfig `yaml:"modules"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModulesConfig holds explicit enable/disable toggles for optional modules.
|
// ModulesConfig holds explicit enable/disable toggles for optional modules.
|
||||||
@@ -31,6 +33,8 @@ type ModulesConfig struct {
|
|||||||
FreeCAD *ModuleToggle `yaml:"freecad"`
|
FreeCAD *ModuleToggle `yaml:"freecad"`
|
||||||
Jobs *ModuleToggle `yaml:"jobs"`
|
Jobs *ModuleToggle `yaml:"jobs"`
|
||||||
DAG *ModuleToggle `yaml:"dag"`
|
DAG *ModuleToggle `yaml:"dag"`
|
||||||
|
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
|
||||||
@@ -109,15 +113,9 @@ type DatabaseConfig struct {
|
|||||||
MaxConnections int `yaml:"max_connections"`
|
MaxConnections int `yaml:"max_connections"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StorageConfig holds object storage settings.
|
// StorageConfig holds file storage settings.
|
||||||
type StorageConfig struct {
|
type StorageConfig struct {
|
||||||
Backend string `yaml:"backend"` // "minio" (default) or "filesystem"
|
Backend string `yaml:"backend"` // "filesystem"
|
||||||
Endpoint string `yaml:"endpoint"`
|
|
||||||
AccessKey string `yaml:"access_key"`
|
|
||||||
SecretKey string `yaml:"secret_key"`
|
|
||||||
Bucket string `yaml:"bucket"`
|
|
||||||
UseSSL bool `yaml:"use_ssl"`
|
|
||||||
Region string `yaml:"region"`
|
|
||||||
Filesystem FilesystemConfig `yaml:"filesystem"`
|
Filesystem FilesystemConfig `yaml:"filesystem"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +144,19 @@ type JobsConfig struct {
|
|||||||
DefaultPriority int `yaml:"default_priority"` // default 100
|
DefaultPriority int `yaml:"default_priority"` // default 100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WorkflowsConfig holds approval workflow definition settings.
|
||||||
|
type WorkflowsConfig struct {
|
||||||
|
Directory string `yaml:"directory"` // default /etc/silo/workflows
|
||||||
|
}
|
||||||
|
|
||||||
|
// SolverConfig holds assembly solver service settings.
|
||||||
|
type SolverConfig struct {
|
||||||
|
DefaultSolver string `yaml:"default_solver"`
|
||||||
|
MaxContextSizeMB int `yaml:"max_context_size_mb"`
|
||||||
|
DefaultTimeout int `yaml:"default_timeout"`
|
||||||
|
AutoDiagnoseOnCommit bool `yaml:"auto_diagnose_on_commit"`
|
||||||
|
}
|
||||||
|
|
||||||
// OdooConfig holds Odoo ERP integration settings.
|
// OdooConfig holds Odoo ERP integration settings.
|
||||||
type OdooConfig struct {
|
type OdooConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
@@ -183,9 +194,6 @@ func Load(path string) (*Config, error) {
|
|||||||
if cfg.Database.MaxConnections == 0 {
|
if cfg.Database.MaxConnections == 0 {
|
||||||
cfg.Database.MaxConnections = 10
|
cfg.Database.MaxConnections = 10
|
||||||
}
|
}
|
||||||
if cfg.Storage.Region == "" {
|
|
||||||
cfg.Storage.Region = "us-east-1"
|
|
||||||
}
|
|
||||||
if cfg.Schemas.Directory == "" {
|
if cfg.Schemas.Directory == "" {
|
||||||
cfg.Schemas.Directory = "/etc/silo/schemas"
|
cfg.Schemas.Directory = "/etc/silo/schemas"
|
||||||
}
|
}
|
||||||
@@ -204,6 +212,15 @@ func Load(path string) (*Config, error) {
|
|||||||
if cfg.Jobs.DefaultPriority == 0 {
|
if cfg.Jobs.DefaultPriority == 0 {
|
||||||
cfg.Jobs.DefaultPriority = 100
|
cfg.Jobs.DefaultPriority = 100
|
||||||
}
|
}
|
||||||
|
if cfg.Workflows.Directory == "" {
|
||||||
|
cfg.Workflows.Directory = "/etc/silo/workflows"
|
||||||
|
}
|
||||||
|
if cfg.Solver.MaxContextSizeMB == 0 {
|
||||||
|
cfg.Solver.MaxContextSizeMB = 10
|
||||||
|
}
|
||||||
|
if cfg.Solver.DefaultTimeout == 0 {
|
||||||
|
cfg.Solver.DefaultTimeout = 300
|
||||||
|
}
|
||||||
|
|
||||||
// Override with environment variables
|
// Override with environment variables
|
||||||
if v := os.Getenv("SILO_DB_HOST"); v != "" {
|
if v := os.Getenv("SILO_DB_HOST"); v != "" {
|
||||||
@@ -218,14 +235,11 @@ func Load(path string) (*Config, error) {
|
|||||||
if v := os.Getenv("SILO_DB_PASSWORD"); v != "" {
|
if v := os.Getenv("SILO_DB_PASSWORD"); v != "" {
|
||||||
cfg.Database.Password = v
|
cfg.Database.Password = v
|
||||||
}
|
}
|
||||||
if v := os.Getenv("SILO_MINIO_ENDPOINT"); v != "" {
|
if v := os.Getenv("SILO_STORAGE_ROOT_DIR"); v != "" {
|
||||||
cfg.Storage.Endpoint = v
|
cfg.Storage.Filesystem.RootDir = v
|
||||||
}
|
}
|
||||||
if v := os.Getenv("SILO_MINIO_ACCESS_KEY"); v != "" {
|
if v := os.Getenv("SILO_SOLVER_DEFAULT"); v != "" {
|
||||||
cfg.Storage.AccessKey = v
|
cfg.Solver.DefaultSolver = v
|
||||||
}
|
|
||||||
if v := os.Getenv("SILO_MINIO_SECRET_KEY"); v != "" {
|
|
||||||
cfg.Storage.SecretKey = v
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth defaults
|
// Auth defaults
|
||||||
|
|||||||
212
internal/db/item_approvals.go
Normal file
212
internal/db/item_approvals.go
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ItemApproval represents a row in the item_approvals table.
|
||||||
|
type ItemApproval struct {
|
||||||
|
ID string
|
||||||
|
ItemID string
|
||||||
|
WorkflowName string
|
||||||
|
ECONumber *string
|
||||||
|
State string // draft | pending | approved | rejected
|
||||||
|
UpdatedAt time.Time
|
||||||
|
UpdatedBy *string
|
||||||
|
Signatures []ApprovalSignature // populated by WithSignatures methods
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApprovalSignature represents a row in the approval_signatures table.
|
||||||
|
type ApprovalSignature struct {
|
||||||
|
ID string
|
||||||
|
ApprovalID string
|
||||||
|
Username string
|
||||||
|
Role string
|
||||||
|
Status string // pending | approved | rejected
|
||||||
|
SignedAt *time.Time
|
||||||
|
Comment *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ItemApprovalRepository provides item_approvals database operations.
|
||||||
|
type ItemApprovalRepository struct {
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewItemApprovalRepository creates a new item approval repository.
|
||||||
|
func NewItemApprovalRepository(db *DB) *ItemApprovalRepository {
|
||||||
|
return &ItemApprovalRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a new approval row. The ID is populated on return.
|
||||||
|
func (r *ItemApprovalRepository) Create(ctx context.Context, a *ItemApproval) error {
|
||||||
|
return r.db.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO item_approvals (item_id, workflow_name, eco_number, state, updated_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, updated_at
|
||||||
|
`, a.ItemID, a.WorkflowName, a.ECONumber, a.State, a.UpdatedBy).Scan(&a.ID, &a.UpdatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddSignature inserts a new signature row. The ID is populated on return.
|
||||||
|
func (r *ItemApprovalRepository) AddSignature(ctx context.Context, s *ApprovalSignature) error {
|
||||||
|
return r.db.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO approval_signatures (approval_id, username, role, status)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id
|
||||||
|
`, s.ApprovalID, s.Username, s.Role, s.Status).Scan(&s.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWithSignatures returns a single approval with its signatures.
|
||||||
|
func (r *ItemApprovalRepository) GetWithSignatures(ctx context.Context, approvalID string) (*ItemApproval, error) {
|
||||||
|
a := &ItemApproval{}
|
||||||
|
err := r.db.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, item_id, workflow_name, eco_number, state, updated_at, updated_by
|
||||||
|
FROM item_approvals
|
||||||
|
WHERE id = $1
|
||||||
|
`, approvalID).Scan(&a.ID, &a.ItemID, &a.WorkflowName, &a.ECONumber, &a.State, &a.UpdatedAt, &a.UpdatedBy)
|
||||||
|
if err != nil {
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("getting approval: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sigs, err := r.signaturesForApproval(ctx, approvalID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a.Signatures = sigs
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByItemWithSignatures returns all approvals for an item, each with signatures.
|
||||||
|
func (r *ItemApprovalRepository) ListByItemWithSignatures(ctx context.Context, itemID string) ([]*ItemApproval, error) {
|
||||||
|
rows, err := r.db.pool.Query(ctx, `
|
||||||
|
SELECT id, item_id, workflow_name, eco_number, state, updated_at, updated_by
|
||||||
|
FROM item_approvals
|
||||||
|
WHERE item_id = $1
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
`, itemID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing approvals: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var approvals []*ItemApproval
|
||||||
|
var approvalIDs []string
|
||||||
|
for rows.Next() {
|
||||||
|
a := &ItemApproval{}
|
||||||
|
if err := rows.Scan(&a.ID, &a.ItemID, &a.WorkflowName, &a.ECONumber, &a.State, &a.UpdatedAt, &a.UpdatedBy); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning approval: %w", err)
|
||||||
|
}
|
||||||
|
approvals = append(approvals, a)
|
||||||
|
approvalIDs = append(approvalIDs, a.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(approvalIDs) == 0 {
|
||||||
|
return approvals, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch-fetch all signatures
|
||||||
|
sigRows, err := r.db.pool.Query(ctx, `
|
||||||
|
SELECT id, approval_id, username, role, status, signed_at, comment
|
||||||
|
FROM approval_signatures
|
||||||
|
WHERE approval_id = ANY($1)
|
||||||
|
ORDER BY username
|
||||||
|
`, approvalIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing signatures: %w", err)
|
||||||
|
}
|
||||||
|
defer sigRows.Close()
|
||||||
|
|
||||||
|
sigMap := make(map[string][]ApprovalSignature)
|
||||||
|
for sigRows.Next() {
|
||||||
|
var s ApprovalSignature
|
||||||
|
if err := sigRows.Scan(&s.ID, &s.ApprovalID, &s.Username, &s.Role, &s.Status, &s.SignedAt, &s.Comment); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning signature: %w", err)
|
||||||
|
}
|
||||||
|
sigMap[s.ApprovalID] = append(sigMap[s.ApprovalID], s)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, a := range approvals {
|
||||||
|
a.Signatures = sigMap[a.ID]
|
||||||
|
if a.Signatures == nil {
|
||||||
|
a.Signatures = []ApprovalSignature{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return approvals, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateState updates the approval state and updated_by.
|
||||||
|
func (r *ItemApprovalRepository) UpdateState(ctx context.Context, approvalID, state, updatedBy string) error {
|
||||||
|
_, err := r.db.pool.Exec(ctx, `
|
||||||
|
UPDATE item_approvals
|
||||||
|
SET state = $2, updated_by = $3, updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
`, approvalID, state, updatedBy)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("updating approval state: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSignatureForUser returns the signature for a specific user on an approval.
|
||||||
|
func (r *ItemApprovalRepository) GetSignatureForUser(ctx context.Context, approvalID, username string) (*ApprovalSignature, error) {
|
||||||
|
s := &ApprovalSignature{}
|
||||||
|
err := r.db.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, approval_id, username, role, status, signed_at, comment
|
||||||
|
FROM approval_signatures
|
||||||
|
WHERE approval_id = $1 AND username = $2
|
||||||
|
`, approvalID, username).Scan(&s.ID, &s.ApprovalID, &s.Username, &s.Role, &s.Status, &s.SignedAt, &s.Comment)
|
||||||
|
if err != nil {
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("getting signature: %w", err)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSignature updates a signature's status, comment, and signed_at timestamp.
|
||||||
|
func (r *ItemApprovalRepository) UpdateSignature(ctx context.Context, sigID, status string, comment *string) error {
|
||||||
|
_, err := r.db.pool.Exec(ctx, `
|
||||||
|
UPDATE approval_signatures
|
||||||
|
SET status = $2, comment = $3, signed_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
`, sigID, status, comment)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("updating signature: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// signaturesForApproval returns all signatures for a single approval.
|
||||||
|
func (r *ItemApprovalRepository) signaturesForApproval(ctx context.Context, approvalID string) ([]ApprovalSignature, error) {
|
||||||
|
rows, err := r.db.pool.Query(ctx, `
|
||||||
|
SELECT id, approval_id, username, role, status, signed_at, comment
|
||||||
|
FROM approval_signatures
|
||||||
|
WHERE approval_id = $1
|
||||||
|
ORDER BY username
|
||||||
|
`, approvalID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing signatures: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sigs []ApprovalSignature
|
||||||
|
for rows.Next() {
|
||||||
|
var s ApprovalSignature
|
||||||
|
if err := rows.Scan(&s.ID, &s.ApprovalID, &s.Username, &s.Role, &s.Status, &s.SignedAt, &s.Comment); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning signature: %w", err)
|
||||||
|
}
|
||||||
|
sigs = append(sigs, s)
|
||||||
|
}
|
||||||
|
if sigs == nil {
|
||||||
|
sigs = []ApprovalSignature{}
|
||||||
|
}
|
||||||
|
return sigs, nil
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ type ItemFile struct {
|
|||||||
ContentType string
|
ContentType string
|
||||||
Size int64
|
Size int64
|
||||||
ObjectKey string
|
ObjectKey string
|
||||||
StorageBackend string // "minio" or "filesystem"
|
StorageBackend string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ func NewItemFileRepository(db *DB) *ItemFileRepository {
|
|||||||
// Create inserts a new item file record.
|
// Create inserts a new item file record.
|
||||||
func (r *ItemFileRepository) Create(ctx context.Context, f *ItemFile) error {
|
func (r *ItemFileRepository) Create(ctx context.Context, f *ItemFile) error {
|
||||||
if f.StorageBackend == "" {
|
if f.StorageBackend == "" {
|
||||||
f.StorageBackend = "minio"
|
f.StorageBackend = "filesystem"
|
||||||
}
|
}
|
||||||
err := r.db.pool.QueryRow(ctx,
|
err := r.db.pool.QueryRow(ctx,
|
||||||
`INSERT INTO item_files (item_id, filename, content_type, size, object_key, storage_backend)
|
`INSERT INTO item_files (item_id, filename, content_type, size, object_key, storage_backend)
|
||||||
@@ -49,7 +49,7 @@ func (r *ItemFileRepository) Create(ctx context.Context, f *ItemFile) error {
|
|||||||
func (r *ItemFileRepository) ListByItem(ctx context.Context, itemID string) ([]*ItemFile, error) {
|
func (r *ItemFileRepository) ListByItem(ctx context.Context, itemID string) ([]*ItemFile, error) {
|
||||||
rows, err := r.db.pool.Query(ctx,
|
rows, err := r.db.pool.Query(ctx,
|
||||||
`SELECT id, item_id, filename, content_type, size, object_key,
|
`SELECT id, item_id, filename, content_type, size, object_key,
|
||||||
COALESCE(storage_backend, 'minio'), created_at
|
COALESCE(storage_backend, 'filesystem'), created_at
|
||||||
FROM item_files WHERE item_id = $1 ORDER BY created_at`,
|
FROM item_files WHERE item_id = $1 ORDER BY created_at`,
|
||||||
itemID,
|
itemID,
|
||||||
)
|
)
|
||||||
@@ -74,7 +74,7 @@ func (r *ItemFileRepository) Get(ctx context.Context, id string) (*ItemFile, err
|
|||||||
f := &ItemFile{}
|
f := &ItemFile{}
|
||||||
err := r.db.pool.QueryRow(ctx,
|
err := r.db.pool.QueryRow(ctx,
|
||||||
`SELECT id, item_id, filename, content_type, size, object_key,
|
`SELECT id, item_id, filename, content_type, size, object_key,
|
||||||
COALESCE(storage_backend, 'minio'), created_at
|
COALESCE(storage_backend, 'filesystem'), created_at
|
||||||
FROM item_files WHERE id = $1`,
|
FROM item_files WHERE id = $1`,
|
||||||
id,
|
id,
|
||||||
).Scan(&f.ID, &f.ItemID, &f.Filename, &f.ContentType, &f.Size, &f.ObjectKey, &f.StorageBackend, &f.CreatedAt)
|
).Scan(&f.ID, &f.ItemID, &f.Filename, &f.ContentType, &f.Size, &f.ObjectKey, &f.StorageBackend, &f.CreatedAt)
|
||||||
|
|||||||
93
internal/db/item_macros.go
Normal file
93
internal/db/item_macros.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ItemMacro represents a row in the item_macros table.
|
||||||
|
type ItemMacro struct {
|
||||||
|
ID string
|
||||||
|
ItemID string
|
||||||
|
Filename string
|
||||||
|
Trigger string
|
||||||
|
Content string
|
||||||
|
RevisionNumber int
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ItemMacroRepository provides item_macros database operations.
|
||||||
|
type ItemMacroRepository struct {
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewItemMacroRepository creates a new item macro repository.
|
||||||
|
func NewItemMacroRepository(db *DB) *ItemMacroRepository {
|
||||||
|
return &ItemMacroRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceForItem atomically replaces all macros for an item.
|
||||||
|
// Deletes existing rows and inserts the new set.
|
||||||
|
func (r *ItemMacroRepository) ReplaceForItem(ctx context.Context, itemID string, revisionNumber int, macros []*ItemMacro) error {
|
||||||
|
return r.db.Tx(ctx, func(tx pgx.Tx) error {
|
||||||
|
_, err := tx.Exec(ctx, `DELETE FROM item_macros WHERE item_id = $1`, itemID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting old macros: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range macros {
|
||||||
|
_, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO item_macros (item_id, filename, trigger, content, revision_number)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
`, itemID, m.Filename, m.Trigger, m.Content, revisionNumber)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inserting macro %s: %w", m.Filename, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByItem returns all macros for an item (without content), ordered by filename.
|
||||||
|
func (r *ItemMacroRepository) ListByItem(ctx context.Context, itemID string) ([]*ItemMacro, error) {
|
||||||
|
rows, err := r.db.pool.Query(ctx, `
|
||||||
|
SELECT id, item_id, filename, trigger, revision_number, created_at
|
||||||
|
FROM item_macros
|
||||||
|
WHERE item_id = $1
|
||||||
|
ORDER BY filename
|
||||||
|
`, itemID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing macros: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var macros []*ItemMacro
|
||||||
|
for rows.Next() {
|
||||||
|
m := &ItemMacro{}
|
||||||
|
if err := rows.Scan(&m.ID, &m.ItemID, &m.Filename, &m.Trigger, &m.RevisionNumber, &m.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning macro: %w", err)
|
||||||
|
}
|
||||||
|
macros = append(macros, m)
|
||||||
|
}
|
||||||
|
return macros, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByFilename returns a single macro by item ID and filename, including content.
|
||||||
|
func (r *ItemMacroRepository) GetByFilename(ctx context.Context, itemID string, filename string) (*ItemMacro, error) {
|
||||||
|
m := &ItemMacro{}
|
||||||
|
err := r.db.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, item_id, filename, trigger, content, revision_number, created_at
|
||||||
|
FROM item_macros
|
||||||
|
WHERE item_id = $1 AND filename = $2
|
||||||
|
`, itemID, filename).Scan(&m.ID, &m.ItemID, &m.Filename, &m.Trigger, &m.Content, &m.RevisionNumber, &m.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("getting macro: %w", err)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
@@ -26,26 +26,26 @@ type Item struct {
|
|||||||
UpdatedBy *string
|
UpdatedBy *string
|
||||||
SourcingType string // "manufactured" or "purchased"
|
SourcingType string // "manufactured" or "purchased"
|
||||||
LongDescription *string // extended description
|
LongDescription *string // extended description
|
||||||
ThumbnailKey *string // MinIO key for item thumbnail
|
ThumbnailKey *string // storage key for item thumbnail
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revision represents a revision record.
|
// Revision represents a revision record.
|
||||||
type Revision struct {
|
type Revision struct {
|
||||||
ID string
|
ID string
|
||||||
ItemID string
|
ItemID string
|
||||||
RevisionNumber int
|
RevisionNumber int
|
||||||
Properties map[string]any
|
Properties map[string]any
|
||||||
FileKey *string
|
FileKey *string
|
||||||
FileVersion *string
|
FileVersion *string
|
||||||
FileChecksum *string
|
FileChecksum *string
|
||||||
FileSize *int64
|
FileSize *int64
|
||||||
FileStorageBackend string // "minio" or "filesystem"
|
FileStorageBackend string
|
||||||
ThumbnailKey *string
|
ThumbnailKey *string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
CreatedBy *string
|
CreatedBy *string
|
||||||
Comment *string
|
Comment *string
|
||||||
Status string // draft, review, released, obsolete
|
Status string // draft, review, released, obsolete
|
||||||
Labels []string // arbitrary tags
|
Labels []string // arbitrary tags
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevisionStatus constants
|
// RevisionStatus constants
|
||||||
@@ -308,7 +308,7 @@ func (r *ItemRepository) CreateRevision(ctx context.Context, rev *Revision) erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rev.FileStorageBackend == "" {
|
if rev.FileStorageBackend == "" {
|
||||||
rev.FileStorageBackend = "minio"
|
rev.FileStorageBackend = "filesystem"
|
||||||
}
|
}
|
||||||
|
|
||||||
err = r.db.pool.QueryRow(ctx, `
|
err = r.db.pool.QueryRow(ctx, `
|
||||||
@@ -347,7 +347,7 @@ func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Re
|
|||||||
if hasStatusColumn {
|
if hasStatusColumn {
|
||||||
rows, err = r.db.pool.Query(ctx, `
|
rows, err = r.db.pool.Query(ctx, `
|
||||||
SELECT id, item_id, revision_number, properties, file_key, file_version,
|
SELECT id, item_id, revision_number, properties, file_key, file_version,
|
||||||
file_checksum, file_size, COALESCE(file_storage_backend, 'minio'),
|
file_checksum, file_size, COALESCE(file_storage_backend, 'filesystem'),
|
||||||
thumbnail_key, created_at, created_by, comment,
|
thumbnail_key, created_at, created_by, comment,
|
||||||
COALESCE(status, 'draft') as status, COALESCE(labels, ARRAY[]::TEXT[]) as labels
|
COALESCE(status, 'draft') as status, COALESCE(labels, ARRAY[]::TEXT[]) as labels
|
||||||
FROM revisions
|
FROM revisions
|
||||||
@@ -386,7 +386,7 @@ func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Re
|
|||||||
)
|
)
|
||||||
rev.Status = "draft"
|
rev.Status = "draft"
|
||||||
rev.Labels = []string{}
|
rev.Labels = []string{}
|
||||||
rev.FileStorageBackend = "minio"
|
rev.FileStorageBackend = "filesystem"
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("scanning revision: %w", err)
|
return nil, fmt.Errorf("scanning revision: %w", err)
|
||||||
@@ -420,7 +420,7 @@ func (r *ItemRepository) GetRevision(ctx context.Context, itemID string, revisio
|
|||||||
if hasStatusColumn {
|
if hasStatusColumn {
|
||||||
err = r.db.pool.QueryRow(ctx, `
|
err = r.db.pool.QueryRow(ctx, `
|
||||||
SELECT id, item_id, revision_number, properties, file_key, file_version,
|
SELECT id, item_id, revision_number, properties, file_key, file_version,
|
||||||
file_checksum, file_size, COALESCE(file_storage_backend, 'minio'),
|
file_checksum, file_size, COALESCE(file_storage_backend, 'filesystem'),
|
||||||
thumbnail_key, created_at, created_by, comment,
|
thumbnail_key, created_at, created_by, comment,
|
||||||
COALESCE(status, 'draft') as status, COALESCE(labels, ARRAY[]::TEXT[]) as labels
|
COALESCE(status, 'draft') as status, COALESCE(labels, ARRAY[]::TEXT[]) as labels
|
||||||
FROM revisions
|
FROM revisions
|
||||||
@@ -443,7 +443,7 @@ func (r *ItemRepository) GetRevision(ctx context.Context, itemID string, revisio
|
|||||||
)
|
)
|
||||||
rev.Status = "draft"
|
rev.Status = "draft"
|
||||||
rev.Labels = []string{}
|
rev.Labels = []string{}
|
||||||
rev.FileStorageBackend = "minio"
|
rev.FileStorageBackend = "filesystem"
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == pgx.ErrNoRows {
|
if err == pgx.ErrNoRows {
|
||||||
|
|||||||
@@ -328,6 +328,55 @@ func (r *JobRepository) ListJobs(ctx context.Context, status, itemID string, lim
|
|||||||
return scanJobs(rows)
|
return scanJobs(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListSolverJobs returns solver jobs (definition_name LIKE 'assembly-%') with optional filters.
|
||||||
|
func (r *JobRepository) ListSolverJobs(ctx context.Context, status, itemID, operation string, limit, offset int) ([]*Job, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, job_definition_id, definition_name, status, priority,
|
||||||
|
item_id, project_id, scope_metadata, runner_id, runner_tags,
|
||||||
|
created_at, claimed_at, started_at, completed_at,
|
||||||
|
timeout_seconds, expires_at, progress, progress_message,
|
||||||
|
result, error_message, retry_count, max_retries,
|
||||||
|
created_by, cancelled_by
|
||||||
|
FROM jobs WHERE definition_name LIKE 'assembly-%'`
|
||||||
|
args := []any{}
|
||||||
|
argN := 1
|
||||||
|
|
||||||
|
if status != "" {
|
||||||
|
query += fmt.Sprintf(" AND status = $%d", argN)
|
||||||
|
args = append(args, status)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
if itemID != "" {
|
||||||
|
query += fmt.Sprintf(" AND item_id = $%d", argN)
|
||||||
|
args = append(args, itemID)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
if operation != "" {
|
||||||
|
query += fmt.Sprintf(" AND scope_metadata->>'operation' = $%d", argN)
|
||||||
|
args = append(args, operation)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY created_at DESC"
|
||||||
|
|
||||||
|
if limit > 0 {
|
||||||
|
query += fmt.Sprintf(" LIMIT $%d", argN)
|
||||||
|
args = append(args, limit)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
if offset > 0 {
|
||||||
|
query += fmt.Sprintf(" OFFSET $%d", argN)
|
||||||
|
args = append(args, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.db.pool.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying solver jobs: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanJobs(rows)
|
||||||
|
}
|
||||||
|
|
||||||
// ClaimJob atomically claims the next available job matching the runner's tags.
|
// ClaimJob atomically claims the next available job matching the runner's tags.
|
||||||
// Uses SELECT FOR UPDATE SKIP LOCKED for exactly-once delivery.
|
// Uses SELECT FOR UPDATE SKIP LOCKED for exactly-once delivery.
|
||||||
func (r *JobRepository) ClaimJob(ctx context.Context, runnerID string, tags []string) (*Job, error) {
|
func (r *JobRepository) ClaimJob(ctx context.Context, runnerID string, tags []string) (*Job, error) {
|
||||||
|
|||||||
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)
|
||||||
|
);
|
||||||
121
internal/db/solver_results.go
Normal file
121
internal/db/solver_results.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SolverResult represents a row in the solver_results table.
|
||||||
|
type SolverResult struct {
|
||||||
|
ID string
|
||||||
|
ItemID string
|
||||||
|
RevisionNumber int
|
||||||
|
JobID *string
|
||||||
|
Operation string // solve, diagnose, kinematic
|
||||||
|
SolverName string
|
||||||
|
Status string // SolveStatus string (Success, Failed, etc.)
|
||||||
|
DOF *int
|
||||||
|
Diagnostics []byte // raw JSONB
|
||||||
|
Placements []byte // raw JSONB
|
||||||
|
NumFrames int
|
||||||
|
SolveTimeMS *float64
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SolverResultRepository provides solver_results database operations.
|
||||||
|
type SolverResultRepository struct {
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSolverResultRepository creates a new solver result repository.
|
||||||
|
func NewSolverResultRepository(db *DB) *SolverResultRepository {
|
||||||
|
return &SolverResultRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert inserts or updates a solver result. The UNIQUE(item_id, revision_number, operation)
|
||||||
|
// constraint means each revision has at most one result per operation type.
|
||||||
|
func (r *SolverResultRepository) Upsert(ctx context.Context, s *SolverResult) error {
|
||||||
|
err := r.db.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO solver_results (item_id, revision_number, job_id, operation,
|
||||||
|
solver_name, status, dof, diagnostics, placements,
|
||||||
|
num_frames, solve_time_ms)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
ON CONFLICT (item_id, revision_number, operation) DO UPDATE SET
|
||||||
|
job_id = EXCLUDED.job_id,
|
||||||
|
solver_name = EXCLUDED.solver_name,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
dof = EXCLUDED.dof,
|
||||||
|
diagnostics = EXCLUDED.diagnostics,
|
||||||
|
placements = EXCLUDED.placements,
|
||||||
|
num_frames = EXCLUDED.num_frames,
|
||||||
|
solve_time_ms = EXCLUDED.solve_time_ms,
|
||||||
|
created_at = now()
|
||||||
|
RETURNING id, created_at
|
||||||
|
`, s.ItemID, s.RevisionNumber, s.JobID, s.Operation,
|
||||||
|
s.SolverName, s.Status, s.DOF, s.Diagnostics, s.Placements,
|
||||||
|
s.NumFrames, s.SolveTimeMS,
|
||||||
|
).Scan(&s.ID, &s.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upserting solver result: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByItem returns all solver results for an item, ordered by revision descending.
|
||||||
|
func (r *SolverResultRepository) GetByItem(ctx context.Context, itemID string) ([]*SolverResult, error) {
|
||||||
|
rows, err := r.db.pool.Query(ctx, `
|
||||||
|
SELECT id, item_id, revision_number, job_id, operation,
|
||||||
|
solver_name, status, dof, diagnostics, placements,
|
||||||
|
num_frames, solve_time_ms, created_at
|
||||||
|
FROM solver_results
|
||||||
|
WHERE item_id = $1
|
||||||
|
ORDER BY revision_number DESC, operation
|
||||||
|
`, itemID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing solver results: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanSolverResults(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByItemRevision returns a single solver result for an item/revision/operation.
|
||||||
|
func (r *SolverResultRepository) GetByItemRevision(ctx context.Context, itemID string, revision int, operation string) (*SolverResult, error) {
|
||||||
|
s := &SolverResult{}
|
||||||
|
err := r.db.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, item_id, revision_number, job_id, operation,
|
||||||
|
solver_name, status, dof, diagnostics, placements,
|
||||||
|
num_frames, solve_time_ms, created_at
|
||||||
|
FROM solver_results
|
||||||
|
WHERE item_id = $1 AND revision_number = $2 AND operation = $3
|
||||||
|
`, itemID, revision, operation).Scan(
|
||||||
|
&s.ID, &s.ItemID, &s.RevisionNumber, &s.JobID, &s.Operation,
|
||||||
|
&s.SolverName, &s.Status, &s.DOF, &s.Diagnostics, &s.Placements,
|
||||||
|
&s.NumFrames, &s.SolveTimeMS, &s.CreatedAt,
|
||||||
|
)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting solver result: %w", err)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanSolverResults(rows pgx.Rows) ([]*SolverResult, error) {
|
||||||
|
var results []*SolverResult
|
||||||
|
for rows.Next() {
|
||||||
|
s := &SolverResult{}
|
||||||
|
if err := rows.Scan(
|
||||||
|
&s.ID, &s.ItemID, &s.RevisionNumber, &s.JobID, &s.Operation,
|
||||||
|
&s.SolverName, &s.Status, &s.DOF, &s.Diagnostics, &s.Placements,
|
||||||
|
&s.NumFrames, &s.SolveTimeMS, &s.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning solver result: %w", err)
|
||||||
|
}
|
||||||
|
results = append(results, s)
|
||||||
|
}
|
||||||
|
return results, rows.Err()
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
@@ -40,11 +40,18 @@ type Dependency struct {
|
|||||||
Relationship string `json:"relationship"`
|
Relationship string `json:"relationship"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MacroFile represents a script file found under silo/macros/.
|
||||||
|
type MacroFile struct {
|
||||||
|
Filename string
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
// ExtractResult holds the parsed silo/ directory contents from a .kc file.
|
// ExtractResult holds the parsed silo/ directory contents from a .kc file.
|
||||||
type ExtractResult struct {
|
type ExtractResult struct {
|
||||||
Manifest *Manifest
|
Manifest *Manifest
|
||||||
Metadata *Metadata
|
Metadata *Metadata
|
||||||
Dependencies []Dependency
|
Dependencies []Dependency
|
||||||
|
Macros []MacroFile
|
||||||
}
|
}
|
||||||
|
|
||||||
// HistoryEntry represents one entry in silo/history.json.
|
// HistoryEntry represents one entry in silo/history.json.
|
||||||
@@ -57,6 +64,26 @@ type HistoryEntry struct {
|
|||||||
Labels []string `json:"labels"`
|
Labels []string `json:"labels"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ApprovalEntry represents one entry in silo/approvals.json.
|
||||||
|
type ApprovalEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
WorkflowName string `json:"workflow"`
|
||||||
|
ECONumber string `json:"eco_number,omitempty"`
|
||||||
|
State string `json:"state"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
UpdatedBy string `json:"updated_by,omitempty"`
|
||||||
|
Signatures []SignatureEntry `json:"signatures"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignatureEntry represents one signer in an approval.
|
||||||
|
type SignatureEntry struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
SignedAt string `json:"signed_at,omitempty"`
|
||||||
|
Comment string `json:"comment,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// PackInput holds all the data needed to repack silo/ entries in a .kc file.
|
// PackInput holds all the data needed to repack silo/ entries in a .kc file.
|
||||||
// Each field is optional — nil/empty means the entry is omitted from the ZIP.
|
// Each field is optional — nil/empty means the entry is omitted from the ZIP.
|
||||||
type PackInput struct {
|
type PackInput struct {
|
||||||
@@ -64,6 +91,7 @@ type PackInput struct {
|
|||||||
Metadata *Metadata
|
Metadata *Metadata
|
||||||
History []HistoryEntry
|
History []HistoryEntry
|
||||||
Dependencies []Dependency
|
Dependencies []Dependency
|
||||||
|
Approvals []ApprovalEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract opens a ZIP archive from data and parses the silo/ directory.
|
// Extract opens a ZIP archive from data and parses the silo/ directory.
|
||||||
@@ -76,6 +104,7 @@ func Extract(data []byte) (*ExtractResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var manifestFile, metadataFile, dependenciesFile *zip.File
|
var manifestFile, metadataFile, dependenciesFile *zip.File
|
||||||
|
var macroFiles []*zip.File
|
||||||
hasSiloDir := false
|
hasSiloDir := false
|
||||||
|
|
||||||
for _, f := range r.File {
|
for _, f := range r.File {
|
||||||
@@ -89,6 +118,13 @@ func Extract(data []byte) (*ExtractResult, error) {
|
|||||||
metadataFile = f
|
metadataFile = f
|
||||||
case "silo/dependencies.json":
|
case "silo/dependencies.json":
|
||||||
dependenciesFile = f
|
dependenciesFile = f
|
||||||
|
default:
|
||||||
|
if strings.HasPrefix(f.Name, "silo/macros/") && !f.FileInfo().IsDir() {
|
||||||
|
name := strings.TrimPrefix(f.Name, "silo/macros/")
|
||||||
|
if name != "" {
|
||||||
|
macroFiles = append(macroFiles, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +160,22 @@ func Extract(data []byte) (*ExtractResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, mf := range macroFiles {
|
||||||
|
rc, err := mf.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("kc: open macro %s: %w", mf.Name, err)
|
||||||
|
}
|
||||||
|
content, err := io.ReadAll(rc)
|
||||||
|
rc.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("kc: read macro %s: %w", mf.Name, err)
|
||||||
|
}
|
||||||
|
result.Macros = append(result.Macros, MacroFile{
|
||||||
|
Filename: strings.TrimPrefix(mf.Name, "silo/macros/"),
|
||||||
|
Content: string(content),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ func Pack(original []byte, input *PackInput) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("kc: writing dependencies.json: %w", err)
|
return nil, fmt.Errorf("kc: writing dependencies.json: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if input.Approvals != nil {
|
||||||
|
if err := writeJSONEntry(zw, "silo/approvals.json", input.Approvals); err != nil {
|
||||||
|
return nil, fmt.Errorf("kc: writing approvals.json: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := zw.Close(); err != nil {
|
if err := zw.Close(); err != nil {
|
||||||
return nil, fmt.Errorf("kc: closing zip writer: %w", err)
|
return nil, fmt.Errorf("kc: closing zip writer: %w", err)
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ func LoadState(r *Registry, cfg *config.Config, pool *pgxpool.Pool) error {
|
|||||||
applyToggle(r, FreeCAD, cfg.Modules.FreeCAD)
|
applyToggle(r, FreeCAD, cfg.Modules.FreeCAD)
|
||||||
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, 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)
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const (
|
|||||||
FreeCAD = "freecad"
|
FreeCAD = "freecad"
|
||||||
Jobs = "jobs"
|
Jobs = "jobs"
|
||||||
DAG = "dag"
|
DAG = "dag"
|
||||||
|
Solver = "solver"
|
||||||
|
Sessions = "sessions"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ModuleInfo describes a module's metadata.
|
// ModuleInfo describes a module's metadata.
|
||||||
@@ -50,7 +52,7 @@ type Registry struct {
|
|||||||
var builtinModules = []ModuleInfo{
|
var builtinModules = []ModuleInfo{
|
||||||
{ID: Core, Name: "Core PDM", Description: "Items, revisions, files, BOM, search, import/export", Required: true, Version: "0.2"},
|
{ID: Core, Name: "Core PDM", Description: "Items, revisions, files, BOM, search, import/export", Required: true, Version: "0.2"},
|
||||||
{ID: Schemas, Name: "Schemas", Description: "Part numbering schema parsing and segment management", Required: true},
|
{ID: Schemas, Name: "Schemas", Description: "Part numbering schema parsing and segment management", Required: true},
|
||||||
{ID: Storage, Name: "Storage", Description: "MinIO/S3 file storage, presigned uploads", Required: true},
|
{ID: Storage, Name: "Storage", Description: "Filesystem storage", Required: true},
|
||||||
{ID: Auth, Name: "Authentication", Description: "Local, LDAP, OIDC authentication and RBAC", DefaultEnabled: true},
|
{ID: Auth, Name: "Authentication", Description: "Local, LDAP, OIDC authentication and RBAC", DefaultEnabled: true},
|
||||||
{ID: Projects, Name: "Projects", Description: "Project management and item tagging", DefaultEnabled: true},
|
{ID: Projects, Name: "Projects", Description: "Project management and item tagging", DefaultEnabled: true},
|
||||||
{ID: Audit, Name: "Audit", Description: "Audit logging, completeness scoring", DefaultEnabled: true},
|
{ID: Audit, Name: "Audit", Description: "Audit logging, completeness scoring", DefaultEnabled: true},
|
||||||
@@ -58,6 +60,8 @@ var builtinModules = []ModuleInfo{
|
|||||||
{ID: FreeCAD, Name: "Create Integration", Description: "URI scheme, executable path, client settings", DefaultEnabled: true},
|
{ID: FreeCAD, Name: "Create Integration", Description: "URI scheme, executable path, client settings", DefaultEnabled: true},
|
||||||
{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: 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) != 10 {
|
if len(all) != 12 {
|
||||||
t.Errorf("expected 10 modules, got %d", len(all))
|
t.Errorf("expected 12 modules, got %d", len(all))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should be sorted by ID.
|
// Should be sorted by ID.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package storage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
@@ -19,3 +20,21 @@ type FileStore interface {
|
|||||||
PresignPut(ctx context.Context, key string, expiry time.Duration) (*url.URL, error)
|
PresignPut(ctx context.Context, key string, expiry time.Duration) (*url.URL, error)
|
||||||
Ping(ctx context.Context) error
|
Ping(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PutResult contains the result of a put operation.
|
||||||
|
type PutResult struct {
|
||||||
|
Key string
|
||||||
|
VersionID string
|
||||||
|
Size int64
|
||||||
|
Checksum string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileKey generates a storage key for an item file.
|
||||||
|
func FileKey(partNumber string, revision int) string {
|
||||||
|
return fmt.Sprintf("items/%s/rev%d.FCStd", partNumber, revision)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThumbnailKey generates a storage key for a thumbnail.
|
||||||
|
func ThumbnailKey(partNumber string, revision int) string {
|
||||||
|
return fmt.Sprintf("thumbnails/%s/rev%d.png", partNumber, revision)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/url"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/minio/minio-go/v7"
|
|
||||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config holds MinIO connection settings.
|
|
||||||
type Config struct {
|
|
||||||
Endpoint string
|
|
||||||
AccessKey string
|
|
||||||
SecretKey string
|
|
||||||
Bucket string
|
|
||||||
UseSSL bool
|
|
||||||
Region string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compile-time check: *Storage implements FileStore.
|
|
||||||
var _ FileStore = (*Storage)(nil)
|
|
||||||
|
|
||||||
// Storage wraps MinIO client operations.
|
|
||||||
type Storage struct {
|
|
||||||
client *minio.Client
|
|
||||||
bucket string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect creates a new MinIO storage client.
|
|
||||||
func Connect(ctx context.Context, cfg Config) (*Storage, error) {
|
|
||||||
client, err := minio.New(cfg.Endpoint, &minio.Options{
|
|
||||||
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
|
||||||
Secure: cfg.UseSSL,
|
|
||||||
Region: cfg.Region,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("creating minio client: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure bucket exists with versioning
|
|
||||||
exists, err := client.BucketExists(ctx, cfg.Bucket)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("checking bucket: %w", err)
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
if err := client.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{
|
|
||||||
Region: cfg.Region,
|
|
||||||
}); err != nil {
|
|
||||||
return nil, fmt.Errorf("creating bucket: %w", err)
|
|
||||||
}
|
|
||||||
// Enable versioning
|
|
||||||
if err := client.EnableVersioning(ctx, cfg.Bucket); err != nil {
|
|
||||||
return nil, fmt.Errorf("enabling versioning: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Storage{client: client, bucket: cfg.Bucket}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PutResult contains the result of a put operation.
|
|
||||||
type PutResult struct {
|
|
||||||
Key string
|
|
||||||
VersionID string
|
|
||||||
Size int64
|
|
||||||
Checksum string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Put uploads a file to storage.
|
|
||||||
func (s *Storage) Put(ctx context.Context, key string, reader io.Reader, size int64, contentType string) (*PutResult, error) {
|
|
||||||
info, err := s.client.PutObject(ctx, s.bucket, key, reader, size, minio.PutObjectOptions{
|
|
||||||
ContentType: contentType,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("uploading object: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &PutResult{
|
|
||||||
Key: key,
|
|
||||||
VersionID: info.VersionID,
|
|
||||||
Size: info.Size,
|
|
||||||
Checksum: info.ChecksumSHA256,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get downloads a file from storage.
|
|
||||||
func (s *Storage) Get(ctx context.Context, key string) (io.ReadCloser, error) {
|
|
||||||
obj, err := s.client.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("getting object: %w", err)
|
|
||||||
}
|
|
||||||
return obj, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetVersion downloads a specific version of a file.
|
|
||||||
func (s *Storage) GetVersion(ctx context.Context, key, versionID string) (io.ReadCloser, error) {
|
|
||||||
obj, err := s.client.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{
|
|
||||||
VersionID: versionID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("getting object version: %w", err)
|
|
||||||
}
|
|
||||||
return obj, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes a file from storage.
|
|
||||||
func (s *Storage) Delete(ctx context.Context, key string) error {
|
|
||||||
if err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{}); err != nil {
|
|
||||||
return fmt.Errorf("removing object: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exists checks if an object exists in storage.
|
|
||||||
func (s *Storage) Exists(ctx context.Context, key string) (bool, error) {
|
|
||||||
_, err := s.client.StatObject(ctx, s.bucket, key, minio.StatObjectOptions{})
|
|
||||||
if err != nil {
|
|
||||||
resp := minio.ToErrorResponse(err)
|
|
||||||
if resp.Code == "NoSuchKey" {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, fmt.Errorf("checking object existence: %w", err)
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ping checks if the storage backend is reachable by verifying the bucket exists.
|
|
||||||
func (s *Storage) Ping(ctx context.Context) error {
|
|
||||||
_, err := s.client.BucketExists(ctx, s.bucket)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bucket returns the bucket name.
|
|
||||||
func (s *Storage) Bucket() string {
|
|
||||||
return s.bucket
|
|
||||||
}
|
|
||||||
|
|
||||||
// PresignPut generates a presigned PUT URL for direct browser upload.
|
|
||||||
func (s *Storage) PresignPut(ctx context.Context, key string, expiry time.Duration) (*url.URL, error) {
|
|
||||||
u, err := s.client.PresignedPutObject(ctx, s.bucket, key, expiry)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("generating presigned put URL: %w", err)
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy copies an object within the same bucket from srcKey to dstKey.
|
|
||||||
func (s *Storage) Copy(ctx context.Context, srcKey, dstKey string) error {
|
|
||||||
src := minio.CopySrcOptions{
|
|
||||||
Bucket: s.bucket,
|
|
||||||
Object: srcKey,
|
|
||||||
}
|
|
||||||
dst := minio.CopyDestOptions{
|
|
||||||
Bucket: s.bucket,
|
|
||||||
Object: dstKey,
|
|
||||||
}
|
|
||||||
if _, err := s.client.CopyObject(ctx, dst, src); err != nil {
|
|
||||||
return fmt.Errorf("copying object: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileKey generates a storage key for an item file.
|
|
||||||
func FileKey(partNumber string, revision int) string {
|
|
||||||
return fmt.Sprintf("items/%s/rev%d.FCStd", partNumber, revision)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ThumbnailKey generates a storage key for a thumbnail.
|
|
||||||
func ThumbnailKey(partNumber string, revision int) string {
|
|
||||||
return fmt.Sprintf("thumbnails/%s/rev%d.png", partNumber, revision)
|
|
||||||
}
|
|
||||||
156
internal/workflow/workflow.go
Normal file
156
internal/workflow/workflow.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
// Package workflow handles YAML approval workflow definition parsing and validation.
|
||||||
|
package workflow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Workflow represents an approval workflow definition loaded from YAML.
|
||||||
|
type Workflow struct {
|
||||||
|
Name string `yaml:"name" json:"name"`
|
||||||
|
Version int `yaml:"version" json:"version"`
|
||||||
|
Description string `yaml:"description" json:"description"`
|
||||||
|
States []string `yaml:"states" json:"states"`
|
||||||
|
Gates []Gate `yaml:"gates" json:"gates"`
|
||||||
|
Rules Rules `yaml:"rules" json:"rules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate defines a required or optional signature role in a workflow.
|
||||||
|
type Gate struct {
|
||||||
|
Role string `yaml:"role" json:"role"`
|
||||||
|
Label string `yaml:"label" json:"label"`
|
||||||
|
Required bool `yaml:"required" json:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rules defines how signatures determine state transitions.
|
||||||
|
type Rules struct {
|
||||||
|
AnyReject string `yaml:"any_reject" json:"any_reject"`
|
||||||
|
AllRequiredApprove string `yaml:"all_required_approve" json:"all_required_approve"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkflowFile wraps a workflow for YAML parsing.
|
||||||
|
type WorkflowFile struct {
|
||||||
|
Workflow Workflow `yaml:"workflow"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var requiredStates = []string{"draft", "pending", "approved", "rejected"}
|
||||||
|
|
||||||
|
// Load reads a workflow definition from a YAML file.
|
||||||
|
func Load(path string) (*Workflow, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading workflow file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wf WorkflowFile
|
||||||
|
if err := yaml.Unmarshal(data, &wf); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing workflow YAML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &wf.Workflow
|
||||||
|
|
||||||
|
if w.Version <= 0 {
|
||||||
|
w.Version = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("validating %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadAll reads all workflow definitions from a directory.
|
||||||
|
func LoadAll(dir string) (map[string]*Workflow, error) {
|
||||||
|
workflows := make(map[string]*Workflow)
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading workflows directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(entry.Name(), ".yaml") && !strings.HasSuffix(entry.Name(), ".yml") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(dir, entry.Name())
|
||||||
|
w, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("loading %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
workflows[w.Name] = w
|
||||||
|
}
|
||||||
|
|
||||||
|
return workflows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks that the workflow definition is well-formed.
|
||||||
|
func (w *Workflow) Validate() error {
|
||||||
|
if w.Name == "" {
|
||||||
|
return fmt.Errorf("workflow name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate states include all required states
|
||||||
|
stateSet := make(map[string]bool, len(w.States))
|
||||||
|
for _, s := range w.States {
|
||||||
|
stateSet[s] = true
|
||||||
|
}
|
||||||
|
for _, rs := range requiredStates {
|
||||||
|
if !stateSet[rs] {
|
||||||
|
return fmt.Errorf("workflow must include state %q", rs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate gates
|
||||||
|
if len(w.Gates) == 0 {
|
||||||
|
return fmt.Errorf("workflow must have at least one gate")
|
||||||
|
}
|
||||||
|
for i, g := range w.Gates {
|
||||||
|
if g.Role == "" {
|
||||||
|
return fmt.Errorf("gate %d: role is required", i)
|
||||||
|
}
|
||||||
|
if g.Label == "" {
|
||||||
|
return fmt.Errorf("gate %d: label is required", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate rules reference valid states
|
||||||
|
if w.Rules.AnyReject != "" && !stateSet[w.Rules.AnyReject] {
|
||||||
|
return fmt.Errorf("rules.any_reject references unknown state %q", w.Rules.AnyReject)
|
||||||
|
}
|
||||||
|
if w.Rules.AllRequiredApprove != "" && !stateSet[w.Rules.AllRequiredApprove] {
|
||||||
|
return fmt.Errorf("rules.all_required_approve references unknown state %q", w.Rules.AllRequiredApprove)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequiredGates returns only the gates where Required is true.
|
||||||
|
func (w *Workflow) RequiredGates() []Gate {
|
||||||
|
var gates []Gate
|
||||||
|
for _, g := range w.Gates {
|
||||||
|
if g.Required {
|
||||||
|
gates = append(gates, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gates
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasRole returns true if the workflow defines a gate with the given role.
|
||||||
|
func (w *Workflow) HasRole(role string) bool {
|
||||||
|
for _, g := range w.Gates {
|
||||||
|
if g.Role == role {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
167
internal/workflow/workflow_test.go
Normal file
167
internal/workflow/workflow_test.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package workflow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoad_Valid(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "test.yaml")
|
||||||
|
os.WriteFile(path, []byte(`
|
||||||
|
workflow:
|
||||||
|
name: test-wf
|
||||||
|
version: 1
|
||||||
|
description: "Test workflow"
|
||||||
|
states: [draft, pending, approved, rejected]
|
||||||
|
gates:
|
||||||
|
- role: reviewer
|
||||||
|
label: "Review"
|
||||||
|
required: true
|
||||||
|
rules:
|
||||||
|
any_reject: rejected
|
||||||
|
all_required_approve: approved
|
||||||
|
`), 0644)
|
||||||
|
|
||||||
|
w, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error: %v", err)
|
||||||
|
}
|
||||||
|
if w.Name != "test-wf" {
|
||||||
|
t.Errorf("Name = %q, want %q", w.Name, "test-wf")
|
||||||
|
}
|
||||||
|
if w.Version != 1 {
|
||||||
|
t.Errorf("Version = %d, want 1", w.Version)
|
||||||
|
}
|
||||||
|
if len(w.Gates) != 1 {
|
||||||
|
t.Fatalf("Gates count = %d, want 1", len(w.Gates))
|
||||||
|
}
|
||||||
|
if w.Gates[0].Role != "reviewer" {
|
||||||
|
t.Errorf("Gates[0].Role = %q, want %q", w.Gates[0].Role, "reviewer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_MissingState(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "bad.yaml")
|
||||||
|
os.WriteFile(path, []byte(`
|
||||||
|
workflow:
|
||||||
|
name: bad
|
||||||
|
states: [draft, pending]
|
||||||
|
gates:
|
||||||
|
- role: r
|
||||||
|
label: "R"
|
||||||
|
required: true
|
||||||
|
`), 0644)
|
||||||
|
|
||||||
|
_, err := Load(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing required states")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_NoGates(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "no-gates.yaml")
|
||||||
|
os.WriteFile(path, []byte(`
|
||||||
|
workflow:
|
||||||
|
name: no-gates
|
||||||
|
states: [draft, pending, approved, rejected]
|
||||||
|
gates: []
|
||||||
|
`), 0644)
|
||||||
|
|
||||||
|
_, err := Load(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for no gates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAll(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
os.WriteFile(filepath.Join(dir, "a.yaml"), []byte(`
|
||||||
|
workflow:
|
||||||
|
name: alpha
|
||||||
|
states: [draft, pending, approved, rejected]
|
||||||
|
gates:
|
||||||
|
- role: r
|
||||||
|
label: "R"
|
||||||
|
required: true
|
||||||
|
rules:
|
||||||
|
any_reject: rejected
|
||||||
|
all_required_approve: approved
|
||||||
|
`), 0644)
|
||||||
|
|
||||||
|
os.WriteFile(filepath.Join(dir, "b.yml"), []byte(`
|
||||||
|
workflow:
|
||||||
|
name: beta
|
||||||
|
states: [draft, pending, approved, rejected]
|
||||||
|
gates:
|
||||||
|
- role: r
|
||||||
|
label: "R"
|
||||||
|
required: true
|
||||||
|
rules:
|
||||||
|
any_reject: rejected
|
||||||
|
`), 0644)
|
||||||
|
|
||||||
|
// Non-yaml file should be ignored
|
||||||
|
os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore me"), 0644)
|
||||||
|
|
||||||
|
wfs, err := LoadAll(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAll() error: %v", err)
|
||||||
|
}
|
||||||
|
if len(wfs) != 2 {
|
||||||
|
t.Fatalf("LoadAll() count = %d, want 2", len(wfs))
|
||||||
|
}
|
||||||
|
if wfs["alpha"] == nil {
|
||||||
|
t.Error("missing workflow 'alpha'")
|
||||||
|
}
|
||||||
|
if wfs["beta"] == nil {
|
||||||
|
t.Error("missing workflow 'beta'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequiredGates(t *testing.T) {
|
||||||
|
w := &Workflow{
|
||||||
|
Gates: []Gate{
|
||||||
|
{Role: "engineer", Label: "Eng", Required: true},
|
||||||
|
{Role: "quality", Label: "QA", Required: false},
|
||||||
|
{Role: "manager", Label: "Mgr", Required: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rg := w.RequiredGates()
|
||||||
|
if len(rg) != 2 {
|
||||||
|
t.Fatalf("RequiredGates() count = %d, want 2", len(rg))
|
||||||
|
}
|
||||||
|
if rg[0].Role != "engineer" || rg[1].Role != "manager" {
|
||||||
|
t.Errorf("RequiredGates() roles = %v, want [engineer, manager]", rg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasRole(t *testing.T) {
|
||||||
|
w := &Workflow{
|
||||||
|
Gates: []Gate{
|
||||||
|
{Role: "engineer", Label: "Eng", Required: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !w.HasRole("engineer") {
|
||||||
|
t.Error("HasRole(engineer) = false, want true")
|
||||||
|
}
|
||||||
|
if w.HasRole("manager") {
|
||||||
|
t.Error("HasRole(manager) = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_InvalidRuleState(t *testing.T) {
|
||||||
|
w := &Workflow{
|
||||||
|
Name: "bad-rule",
|
||||||
|
States: []string{"draft", "pending", "approved", "rejected"},
|
||||||
|
Gates: []Gate{{Role: "r", Label: "R", Required: true}},
|
||||||
|
Rules: Rules{AnyReject: "nonexistent"},
|
||||||
|
}
|
||||||
|
if err := w.Validate(); err == nil {
|
||||||
|
t.Fatal("expected error for invalid rule state reference")
|
||||||
|
}
|
||||||
|
}
|
||||||
23
jobdefs/assembly-kinematic.yaml
Normal file
23
jobdefs/assembly-kinematic.yaml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
job:
|
||||||
|
name: assembly-kinematic
|
||||||
|
version: 1
|
||||||
|
description: "Run kinematic simulation"
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
type: manual
|
||||||
|
|
||||||
|
scope:
|
||||||
|
type: assembly
|
||||||
|
|
||||||
|
compute:
|
||||||
|
type: custom
|
||||||
|
command: solver-kinematic
|
||||||
|
args:
|
||||||
|
operation: kinematic
|
||||||
|
|
||||||
|
runner:
|
||||||
|
tags: [solver]
|
||||||
|
|
||||||
|
timeout: 1800
|
||||||
|
max_retries: 0
|
||||||
|
priority: 100
|
||||||
21
jobdefs/assembly-solve.yaml
Normal file
21
jobdefs/assembly-solve.yaml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
job:
|
||||||
|
name: assembly-solve
|
||||||
|
version: 1
|
||||||
|
description: "Solve assembly constraints on server"
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
type: manual
|
||||||
|
|
||||||
|
scope:
|
||||||
|
type: assembly
|
||||||
|
|
||||||
|
compute:
|
||||||
|
type: custom
|
||||||
|
command: solver-run
|
||||||
|
|
||||||
|
runner:
|
||||||
|
tags: [solver]
|
||||||
|
|
||||||
|
timeout: 300
|
||||||
|
max_retries: 1
|
||||||
|
priority: 50
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
job:
|
job:
|
||||||
name: assembly-validate
|
name: assembly-validate
|
||||||
version: 1
|
version: 1
|
||||||
description: "Validate assembly by rebuilding its dependency subgraph"
|
description: "Validate assembly constraints on commit"
|
||||||
|
|
||||||
trigger:
|
trigger:
|
||||||
type: revision_created
|
type: revision_created
|
||||||
@@ -12,15 +12,14 @@ job:
|
|||||||
type: assembly
|
type: assembly
|
||||||
|
|
||||||
compute:
|
compute:
|
||||||
type: validate
|
type: custom
|
||||||
command: create-validate
|
command: solver-diagnose
|
||||||
args:
|
args:
|
||||||
rebuild_mode: incremental
|
operation: diagnose
|
||||||
check_interference: true
|
|
||||||
|
|
||||||
runner:
|
runner:
|
||||||
tags: [create]
|
tags: [solver]
|
||||||
|
|
||||||
timeout: 900
|
timeout: 120
|
||||||
max_retries: 2
|
max_retries: 2
|
||||||
priority: 50
|
priority: 75
|
||||||
|
|||||||
2
migrations/019_approval_workflow_name.sql
Normal file
2
migrations/019_approval_workflow_name.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add workflow_name column to item_approvals for YAML-configurable approval workflows.
|
||||||
|
ALTER TABLE item_approvals ADD COLUMN workflow_name TEXT NOT NULL DEFAULT 'default';
|
||||||
3
migrations/020_storage_backend_filesystem_default.sql
Normal file
3
migrations/020_storage_backend_filesystem_default.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- Change default storage backend from 'minio' to 'filesystem'.
|
||||||
|
ALTER TABLE item_files ALTER COLUMN storage_backend SET DEFAULT 'filesystem';
|
||||||
|
ALTER TABLE revisions ALTER COLUMN file_storage_backend SET DEFAULT 'filesystem';
|
||||||
29
migrations/021_solver_results.sql
Normal file
29
migrations/021_solver_results.sql
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
-- Migration 021: Solver result cache table
|
||||||
|
--
|
||||||
|
-- Stores the latest solve/diagnose/kinematic result per item revision.
|
||||||
|
-- The UNIQUE constraint means re-running an operation overwrites the previous result.
|
||||||
|
-- See docs/SOLVER.md Section 9.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE solver_results (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
revision_number INTEGER NOT NULL,
|
||||||
|
job_id UUID REFERENCES jobs(id) ON DELETE SET NULL,
|
||||||
|
operation TEXT NOT NULL, -- 'solve', 'diagnose', 'kinematic'
|
||||||
|
solver_name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL, -- SolveStatus string ('Success', 'Failed', etc.)
|
||||||
|
dof INTEGER,
|
||||||
|
diagnostics JSONB DEFAULT '[]',
|
||||||
|
placements JSONB DEFAULT '[]',
|
||||||
|
num_frames INTEGER DEFAULT 0,
|
||||||
|
solve_time_ms DOUBLE PRECISION,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(item_id, revision_number, operation)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_solver_results_item ON solver_results(item_id);
|
||||||
|
CREATE INDEX idx_solver_results_status ON solver_results(status);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
# - SSH access to the target host
|
# - SSH access to the target host
|
||||||
# - /etc/silo/silod.env must exist on target with credentials filled in
|
# - /etc/silo/silod.env must exist on target with credentials filled in
|
||||||
# - PostgreSQL reachable from target (set SILO_DB_HOST to override)
|
# - PostgreSQL reachable from target (set SILO_DB_HOST to override)
|
||||||
# - MinIO reachable from target (set SILO_MINIO_HOST to override)
|
|
||||||
#
|
#
|
||||||
# Environment variables:
|
# Environment variables:
|
||||||
# SILO_DEPLOY_TARGET - target host (default: silo.example.internal)
|
# SILO_DEPLOY_TARGET - target host (default: silo.example.internal)
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Migrate storage from MinIO to filesystem on a remote Silo host.
|
|
||||||
#
|
|
||||||
# Builds the migrate-storage binary locally, uploads it to the target host,
|
|
||||||
# then runs it over SSH using credentials from /etc/silo/silod.env.
|
|
||||||
#
|
|
||||||
# Usage: ./scripts/migrate-storage.sh <silo-host> <psql-host> <minio-host> [flags...]
|
|
||||||
#
|
|
||||||
# Examples:
|
|
||||||
# ./scripts/migrate-storage.sh silo.kindred.internal psql.kindred.internal minio.kindred.internal -dry-run -verbose
|
|
||||||
# ./scripts/migrate-storage.sh silo.kindred.internal psql.kindred.internal minio.kindred.internal
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [ $# -lt 3 ]; then
|
|
||||||
echo "Usage: $0 <silo-host> <psql-host> <minio-host> [flags...]"
|
|
||||||
echo " flags are passed to migrate-storage (e.g. -dry-run -verbose)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
TARGET="$1"
|
|
||||||
DB_HOST="$2"
|
|
||||||
MINIO_HOST="$3"
|
|
||||||
shift 3
|
|
||||||
EXTRA_FLAGS="$*"
|
|
||||||
|
|
||||||
DEST_DIR="/opt/silo/data"
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
PROJECT_DIR="${SCRIPT_DIR}/.."
|
|
||||||
|
|
||||||
echo "=== Migrate Storage: MinIO -> Filesystem ==="
|
|
||||||
echo " Target: ${TARGET}"
|
|
||||||
echo " DB host: ${DB_HOST}"
|
|
||||||
echo " MinIO: ${MINIO_HOST}"
|
|
||||||
echo " Dest: ${DEST_DIR}"
|
|
||||||
[ -n "$EXTRA_FLAGS" ] && echo " Flags: ${EXTRA_FLAGS}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# --- Build locally ---
|
|
||||||
echo "[1/3] Building migrate-storage binary..."
|
|
||||||
cd "$PROJECT_DIR"
|
|
||||||
GOOS=linux GOARCH=amd64 go build -o migrate-storage ./cmd/migrate-storage
|
|
||||||
echo " Built: $(du -h migrate-storage | cut -f1)"
|
|
||||||
|
|
||||||
# --- Upload ---
|
|
||||||
echo "[2/3] Uploading to ${TARGET}..."
|
|
||||||
scp migrate-storage "${TARGET}:/tmp/migrate-storage"
|
|
||||||
rm -f migrate-storage
|
|
||||||
|
|
||||||
# --- Run remotely ---
|
|
||||||
echo "[3/3] Running migration on ${TARGET}..."
|
|
||||||
ssh "$TARGET" DB_HOST="$DB_HOST" MINIO_HOST="$MINIO_HOST" DEST_DIR="$DEST_DIR" EXTRA_FLAGS="$EXTRA_FLAGS" bash -s <<'REMOTE'
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
CONFIG_DIR="/etc/silo"
|
|
||||||
|
|
||||||
# Source credentials
|
|
||||||
if [ ! -f "$CONFIG_DIR/silod.env" ]; then
|
|
||||||
echo "ERROR: $CONFIG_DIR/silod.env not found on $(hostname)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
set -a
|
|
||||||
source "$CONFIG_DIR/silod.env"
|
|
||||||
set +a
|
|
||||||
|
|
||||||
# Ensure destination directory exists
|
|
||||||
sudo mkdir -p "$DEST_DIR"
|
|
||||||
sudo chown silo:silo "$DEST_DIR" 2>/dev/null || true
|
|
||||||
|
|
||||||
chmod +x /tmp/migrate-storage
|
|
||||||
|
|
||||||
# Write temporary config with the provided hosts
|
|
||||||
cat > /tmp/silo-migrate.yaml <<EOF
|
|
||||||
database:
|
|
||||||
host: "${DB_HOST}"
|
|
||||||
port: 5432
|
|
||||||
name: "silo"
|
|
||||||
user: "silo"
|
|
||||||
password: "${SILO_DB_PASSWORD}"
|
|
||||||
sslmode: "require"
|
|
||||||
max_connections: 5
|
|
||||||
|
|
||||||
storage:
|
|
||||||
endpoint: "${MINIO_HOST}:9000"
|
|
||||||
access_key: "${SILO_MINIO_ACCESS_KEY}"
|
|
||||||
secret_key: "${SILO_MINIO_SECRET_KEY}"
|
|
||||||
bucket: "silo"
|
|
||||||
use_ssl: false
|
|
||||||
region: "us-east-1"
|
|
||||||
EOF
|
|
||||||
chmod 600 /tmp/silo-migrate.yaml
|
|
||||||
|
|
||||||
echo " Config written to /tmp/silo-migrate.yaml"
|
|
||||||
echo " Starting migration..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Run the migration
|
|
||||||
/tmp/migrate-storage -config /tmp/silo-migrate.yaml -dest "$DEST_DIR" $EXTRA_FLAGS
|
|
||||||
|
|
||||||
# Clean up
|
|
||||||
rm -f /tmp/silo-migrate.yaml /tmp/migrate-storage
|
|
||||||
echo ""
|
|
||||||
echo " Cleaned up temp files."
|
|
||||||
REMOTE
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Migration complete ==="
|
|
||||||
echo " Files written to ${TARGET}:${DEST_DIR}"
|
|
||||||
@@ -138,12 +138,6 @@ fi
|
|||||||
PG_PASSWORD_DEFAULT="$(generate_secret 16)"
|
PG_PASSWORD_DEFAULT="$(generate_secret 16)"
|
||||||
prompt_secret POSTGRES_PASSWORD "PostgreSQL password" "$PG_PASSWORD_DEFAULT"
|
prompt_secret POSTGRES_PASSWORD "PostgreSQL password" "$PG_PASSWORD_DEFAULT"
|
||||||
|
|
||||||
# MinIO
|
|
||||||
MINIO_AK_DEFAULT="$(generate_secret 10)"
|
|
||||||
MINIO_SK_DEFAULT="$(generate_secret 16)"
|
|
||||||
prompt_secret MINIO_ACCESS_KEY "MinIO access key" "$MINIO_AK_DEFAULT"
|
|
||||||
prompt_secret MINIO_SECRET_KEY "MinIO secret key" "$MINIO_SK_DEFAULT"
|
|
||||||
|
|
||||||
# OpenLDAP
|
# OpenLDAP
|
||||||
LDAP_ADMIN_PW_DEFAULT="$(generate_secret 16)"
|
LDAP_ADMIN_PW_DEFAULT="$(generate_secret 16)"
|
||||||
prompt_secret LDAP_ADMIN_PASSWORD "LDAP admin password" "$LDAP_ADMIN_PW_DEFAULT"
|
prompt_secret LDAP_ADMIN_PASSWORD "LDAP admin password" "$LDAP_ADMIN_PW_DEFAULT"
|
||||||
@@ -173,10 +167,6 @@ cat > "${OUTPUT_DIR}/.env" << EOF
|
|||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
|
||||||
# MinIO
|
|
||||||
MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
|
|
||||||
MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
|
|
||||||
|
|
||||||
# OpenLDAP
|
# OpenLDAP
|
||||||
LDAP_ADMIN_PASSWORD=${LDAP_ADMIN_PASSWORD}
|
LDAP_ADMIN_PASSWORD=${LDAP_ADMIN_PASSWORD}
|
||||||
LDAP_USERS=${LDAP_USERS}
|
LDAP_USERS=${LDAP_USERS}
|
||||||
@@ -235,12 +225,9 @@ database:
|
|||||||
max_connections: 10
|
max_connections: 10
|
||||||
|
|
||||||
storage:
|
storage:
|
||||||
endpoint: "minio:9000"
|
backend: "filesystem"
|
||||||
access_key: "${SILO_MINIO_ACCESS_KEY}"
|
filesystem:
|
||||||
secret_key: "${SILO_MINIO_SECRET_KEY}"
|
root_dir: "/var/lib/silo/data"
|
||||||
bucket: "silo-files"
|
|
||||||
use_ssl: false
|
|
||||||
region: "us-east-1"
|
|
||||||
|
|
||||||
schemas:
|
schemas:
|
||||||
directory: "/etc/silo/schemas"
|
directory: "/etc/silo/schemas"
|
||||||
@@ -306,8 +293,6 @@ echo " deployments/config.docker.yaml - server configuration"
|
|||||||
echo ""
|
echo ""
|
||||||
echo -e "${BOLD}Credentials:${NC}"
|
echo -e "${BOLD}Credentials:${NC}"
|
||||||
echo " PostgreSQL: silo / ${POSTGRES_PASSWORD}"
|
echo " PostgreSQL: silo / ${POSTGRES_PASSWORD}"
|
||||||
echo " MinIO: ${MINIO_ACCESS_KEY} / ${MINIO_SECRET_KEY}"
|
|
||||||
echo " MinIO Console: http://localhost:9001"
|
|
||||||
echo " LDAP Admin: cn=admin,dc=silo,dc=local / ${LDAP_ADMIN_PASSWORD}"
|
echo " LDAP Admin: cn=admin,dc=silo,dc=local / ${LDAP_ADMIN_PASSWORD}"
|
||||||
echo " LDAP User: ${LDAP_USERS} / ${LDAP_PASSWORDS}"
|
echo " LDAP User: ${LDAP_USERS} / ${LDAP_PASSWORDS}"
|
||||||
echo " Silo Admin: ${SILO_ADMIN_USERNAME} / ${SILO_ADMIN_PASSWORD} (local fallback)"
|
echo " Silo Admin: ${SILO_ADMIN_USERNAME} / ${SILO_ADMIN_PASSWORD} (local fallback)"
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ INSTALL_DIR="/opt/silo"
|
|||||||
CONFIG_DIR="/etc/silo"
|
CONFIG_DIR="/etc/silo"
|
||||||
GO_VERSION="1.24.0"
|
GO_VERSION="1.24.0"
|
||||||
DB_HOST="${SILO_DB_HOST:-psql.example.internal}"
|
DB_HOST="${SILO_DB_HOST:-psql.example.internal}"
|
||||||
MINIO_HOST="${SILO_MINIO_HOST:-minio.example.internal}"
|
|
||||||
|
|
||||||
log_info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
log_info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||||
log_success() { echo -e "${GREEN}[OK]${NC} $*"; }
|
log_success() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||||
@@ -165,11 +164,6 @@ if [[ ! -f "${ENV_FILE}" ]]; then
|
|||||||
# Database: silo, User: silo
|
# Database: silo, User: silo
|
||||||
SILO_DB_PASSWORD=
|
SILO_DB_PASSWORD=
|
||||||
|
|
||||||
# MinIO credentials (${MINIO_HOST})
|
|
||||||
# User: silouser
|
|
||||||
SILO_MINIO_ACCESS_KEY=silouser
|
|
||||||
SILO_MINIO_SECRET_KEY=
|
|
||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
# Session secret (required when auth is enabled)
|
# Session secret (required when auth is enabled)
|
||||||
SILO_SESSION_SECRET=
|
SILO_SESSION_SECRET=
|
||||||
@@ -225,10 +219,7 @@ echo ""
|
|||||||
echo "2. Verify database connectivity:"
|
echo "2. Verify database connectivity:"
|
||||||
echo " psql -h ${DB_HOST} -U silo -d silo -c 'SELECT 1'"
|
echo " psql -h ${DB_HOST} -U silo -d silo -c 'SELECT 1'"
|
||||||
echo ""
|
echo ""
|
||||||
echo "3. Verify MinIO connectivity:"
|
echo "3. Run the deployment:"
|
||||||
echo " curl -I http://${MINIO_HOST}:9000/minio/health/live"
|
|
||||||
echo ""
|
|
||||||
echo "4. Run the deployment:"
|
|
||||||
echo " sudo ${INSTALL_DIR}/src/scripts/deploy.sh"
|
echo " sudo ${INSTALL_DIR}/src/scripts/deploy.sh"
|
||||||
echo ""
|
echo ""
|
||||||
echo "After deployment, manage the service with:"
|
echo "After deployment, manage the service with:"
|
||||||
|
|||||||
25
workflows/engineering-change.yaml
Normal file
25
workflows/engineering-change.yaml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
workflow:
|
||||||
|
name: engineering-change
|
||||||
|
version: 1
|
||||||
|
description: "Standard engineering change order with peer review and manager approval"
|
||||||
|
|
||||||
|
states:
|
||||||
|
- draft
|
||||||
|
- pending
|
||||||
|
- approved
|
||||||
|
- rejected
|
||||||
|
|
||||||
|
gates:
|
||||||
|
- role: engineer
|
||||||
|
label: "Peer Review"
|
||||||
|
required: true
|
||||||
|
- role: manager
|
||||||
|
label: "Manager Approval"
|
||||||
|
required: true
|
||||||
|
- role: quality
|
||||||
|
label: "Quality Sign-off"
|
||||||
|
required: false
|
||||||
|
|
||||||
|
rules:
|
||||||
|
any_reject: rejected
|
||||||
|
all_required_approve: approved
|
||||||
19
workflows/quick-review.yaml
Normal file
19
workflows/quick-review.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
workflow:
|
||||||
|
name: quick-review
|
||||||
|
version: 1
|
||||||
|
description: "Single reviewer approval for minor changes"
|
||||||
|
|
||||||
|
states:
|
||||||
|
- draft
|
||||||
|
- pending
|
||||||
|
- approved
|
||||||
|
- rejected
|
||||||
|
|
||||||
|
gates:
|
||||||
|
- role: reviewer
|
||||||
|
label: "Review"
|
||||||
|
required: true
|
||||||
|
|
||||||
|
rules:
|
||||||
|
any_reject: rejected
|
||||||
|
all_required_approve: approved
|
||||||
Reference in New Issue
Block a user