Compare commits
20 Commits
feat/kc-ma
...
feat/edit-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68c9acea5c | ||
|
|
a669327042 | ||
|
|
e7da3ee94d | ||
|
|
a851630d85 | ||
| e5cae28a8c | |||
|
|
5f144878d6 | ||
| ed1ac45e12 | |||
|
|
88d1ab1f97 | ||
|
|
12ecffdabe | ||
| e260c175bf | |||
|
|
bae06da1a1 | ||
| 161c1c1e62 | |||
| df0fc13193 | |||
| 98be1fa78c | |||
| f8b8eda973 | |||
| 1a34455ad5 | |||
| 28f133411e | |||
| 6528df0461 | |||
| 628cd1d252 | |||
|
|
8d777e83bb |
@@ -5,10 +5,6 @@
|
||||
# PostgreSQL
|
||||
POSTGRES_PASSWORD=silodev
|
||||
|
||||
# MinIO
|
||||
MINIO_ACCESS_KEY=silominio
|
||||
MINIO_SECRET_KEY=silominiosecret
|
||||
|
||||
# OpenLDAP
|
||||
LDAP_ADMIN_PASSWORD=ldapadmin
|
||||
LDAP_USERS=siloadmin
|
||||
|
||||
17
Makefile
17
Makefile
@@ -1,8 +1,7 @@
|
||||
.PHONY: build run test test-integration clean migrate fmt lint \
|
||||
docker-build docker-up docker-down docker-logs docker-ps \
|
||||
docker-clean docker-rebuild \
|
||||
web-install web-dev web-build \
|
||||
migrate-storage
|
||||
web-install web-dev web-build
|
||||
|
||||
# =============================================================================
|
||||
# Local Development
|
||||
@@ -57,13 +56,6 @@ tidy:
|
||||
migrate:
|
||||
./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)
|
||||
db-shell:
|
||||
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 -t silo:latest -f build/package/Dockerfile .
|
||||
|
||||
# Start the full stack (postgres + minio + silo)
|
||||
# Start the full stack (postgres + silo)
|
||||
docker-up:
|
||||
docker compose -f deployments/docker-compose.yaml up -d
|
||||
|
||||
@@ -103,9 +95,6 @@ docker-logs-silo:
|
||||
docker-logs-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
|
||||
docker-ps:
|
||||
docker compose -f deployments/docker-compose.yaml ps
|
||||
@@ -175,7 +164,7 @@ help:
|
||||
@echo ""
|
||||
@echo "Docker:"
|
||||
@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-clean - Stop and remove volumes (deletes data)"
|
||||
@echo " docker-logs - View all logs"
|
||||
|
||||
@@ -34,7 +34,7 @@ silo/
|
||||
│ ├── ods/ # ODS spreadsheet library
|
||||
│ ├── partnum/ # Part number generation
|
||||
│ ├── schema/ # YAML schema parsing
|
||||
│ ├── storage/ # MinIO file storage
|
||||
│ ├── storage/ # Filesystem storage
|
||||
│ └── testutil/ # Test helpers
|
||||
├── web/ # React SPA (Vite + TypeScript)
|
||||
│ └── src/
|
||||
@@ -55,7 +55,7 @@ silo/
|
||||
|
||||
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
|
||||
./scripts/setup-docker.sh
|
||||
@@ -65,7 +65,7 @@ docker compose -f deployments/docker-compose.allinone.yaml up -d
|
||||
**Development (local Go + Docker services):**
|
||||
|
||||
```bash
|
||||
make docker-up # Start PostgreSQL + MinIO in Docker
|
||||
make docker-up # Start PostgreSQL in Docker
|
||||
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/schema"
|
||||
"github.com/kindredsystems/silo/internal/storage"
|
||||
"github.com/kindredsystems/silo/internal/workflow"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
@@ -44,7 +45,6 @@ func main() {
|
||||
Str("host", cfg.Server.Host).
|
||||
Int("port", cfg.Server.Port).
|
||||
Str("database", cfg.Database.Host).
|
||||
Str("storage", cfg.Storage.Endpoint).
|
||||
Msg("starting silo server")
|
||||
|
||||
// Connect to database
|
||||
@@ -64,40 +64,17 @@ func main() {
|
||||
defer database.Close()
|
||||
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
|
||||
switch cfg.Storage.Backend {
|
||||
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\"")
|
||||
}
|
||||
if cfg.Storage.Filesystem.RootDir != "" {
|
||||
s, fsErr := storage.NewFilesystemStore(cfg.Storage.Filesystem.RootDir)
|
||||
if fsErr != nil {
|
||||
logger.Fatal().Err(fsErr).Msg("failed to initialize filesystem storage")
|
||||
}
|
||||
store = s
|
||||
logger.Info().Str("root", cfg.Storage.Filesystem.RootDir).Msg("connected to filesystem storage")
|
||||
default:
|
||||
logger.Fatal().Str("backend", cfg.Storage.Backend).Msg("unknown storage backend")
|
||||
} else {
|
||||
logger.Info().Msg("storage not configured - file operations disabled")
|
||||
}
|
||||
|
||||
// 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
|
||||
registry := modules.NewRegistry()
|
||||
if err := modules.LoadState(registry, cfg, database.Pool()); err != nil {
|
||||
@@ -258,7 +248,7 @@ func main() {
|
||||
// Create API server
|
||||
server := api.NewServer(logger, database, schemas, cfg.Schemas.Directory, store,
|
||||
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)
|
||||
|
||||
// Start background sweepers for job/runner timeouts (only when jobs module enabled)
|
||||
|
||||
@@ -17,17 +17,9 @@ database:
|
||||
max_connections: 10
|
||||
|
||||
storage:
|
||||
backend: "minio" # "minio" (default) or "filesystem"
|
||||
# MinIO/S3 settings (used when backend: "minio")
|
||||
endpoint: "localhost:9000" # Use "minio:9000" for Docker Compose
|
||||
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"
|
||||
backend: "filesystem"
|
||||
filesystem:
|
||||
root_dir: "/opt/silo/data" # Override with SILO_STORAGE_ROOT_DIR env var
|
||||
|
||||
schemas:
|
||||
# Directory containing YAML schema files
|
||||
|
||||
@@ -17,12 +17,9 @@ database:
|
||||
max_connections: 10
|
||||
|
||||
storage:
|
||||
endpoint: "minio:9000"
|
||||
access_key: "${MINIO_ACCESS_KEY:-silominio}"
|
||||
secret_key: "${MINIO_SECRET_KEY:-silominiosecret}"
|
||||
bucket: "silo-files"
|
||||
use_ssl: false
|
||||
region: "us-east-1"
|
||||
backend: "filesystem"
|
||||
filesystem:
|
||||
root_dir: "/var/lib/silo/data"
|
||||
|
||||
schemas:
|
||||
directory: "/etc/silo/schemas"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Silo All-in-One Stack
|
||||
# PostgreSQL + MinIO + OpenLDAP + Silo API + Nginx (optional)
|
||||
# PostgreSQL + OpenLDAP + Silo API + Nginx (optional)
|
||||
#
|
||||
# Quick start:
|
||||
# ./scripts/setup-docker.sh
|
||||
@@ -40,29 +40,6 @@ services:
|
||||
networks:
|
||||
- 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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -83,9 +60,13 @@ services:
|
||||
- openldap_data:/bitnami/openldap
|
||||
- ./ldap:/docker-entrypoint-initdb.d:ro
|
||||
ports:
|
||||
- "1389:1389" # LDAP access for debugging (remove in hardened setups)
|
||||
- "1389:1389" # LDAP access for debugging (remove in hardened setups)
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -104,8 +85,6 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
openldap:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
@@ -117,12 +96,10 @@ services:
|
||||
SILO_DB_NAME: silo
|
||||
SILO_DB_USER: silo
|
||||
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:
|
||||
- "${SILO_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- silo_data:/var/lib/silo/data
|
||||
- ../schemas:/etc/silo/schemas:ro
|
||||
- ./config.docker.yaml:/etc/silo/config.yaml:ro
|
||||
healthcheck:
|
||||
@@ -164,7 +141,7 @@ services:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
silo_data:
|
||||
openldap_data:
|
||||
|
||||
networks:
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
# 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:
|
||||
# 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
|
||||
|
||||
services:
|
||||
@@ -24,14 +22,6 @@ services:
|
||||
# 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}
|
||||
# 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:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
|
||||
@@ -19,26 +19,6 @@ services:
|
||||
networks:
|
||||
- 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:
|
||||
build:
|
||||
context: ..
|
||||
@@ -47,19 +27,12 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
SILO_DB_HOST: postgres
|
||||
SILO_DB_PORT: 5432
|
||||
SILO_DB_NAME: silo
|
||||
SILO_DB_USER: silo
|
||||
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_OIDC_CLIENT_SECRET: ${SILO_OIDC_CLIENT_SECRET:-}
|
||||
SILO_LDAP_BIND_PASSWORD: ${SILO_LDAP_BIND_PASSWORD:-}
|
||||
@@ -68,6 +41,7 @@ services:
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- silo_data:/var/lib/silo/data
|
||||
- ../schemas:/etc/silo/schemas:ro
|
||||
- ./config.dev.yaml:/etc/silo/config.yaml:ro
|
||||
healthcheck:
|
||||
@@ -80,7 +54,7 @@ services:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
silo_data:
|
||||
|
||||
networks:
|
||||
silo-network:
|
||||
|
||||
@@ -27,6 +27,7 @@ NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
ReadWritePaths=/opt/silo/data
|
||||
ReadOnlyPaths=/etc/silo /opt/silo
|
||||
|
||||
# Resource limits
|
||||
|
||||
485
docs/KC_SERVER.md
Normal file
485
docs/KC_SERVER.md
Normal file
@@ -0,0 +1,485 @@
|
||||
# .kc Server-Side Metadata Integration
|
||||
|
||||
**Status:** Draft
|
||||
**Date:** February 2026
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
When a `.kc` file is committed to Silo, the server extracts and indexes the `silo/` directory contents so that metadata is queryable, diffable, and streamable without downloading the full file. This document specifies the server-side processing pipeline, database storage, API endpoints, and SSE events that support the Create viewport widgets defined in [SILO_VIEWPORT.md](SILO_VIEWPORT.md).
|
||||
|
||||
The core principle: **the `.kc` file is the transport format; Silo is the index.** The `silo/` directory entries are extracted into database columns on commit and packed back into the ZIP on checkout. The server never modifies the FreeCAD standard zone (`Document.xml`, `.brp` files, `thumbnails/`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Commit Pipeline
|
||||
|
||||
When a `.kc` file is uploaded via `POST /api/items/{partNumber}/file`, the server runs an extraction pipeline before returning success.
|
||||
|
||||
### 2.1 Pipeline Steps
|
||||
|
||||
```
|
||||
Client uploads .kc file
|
||||
|
|
||||
v
|
||||
+-----------------------------+
|
||||
| 1. Store file to disk | (existing behavior -- unchanged)
|
||||
| items/{pn}/rev{N}.kc |
|
||||
+-----------------------------+
|
||||
|
|
||||
v
|
||||
+-----------------------------+
|
||||
| 2. Open ZIP, read silo/ |
|
||||
| Parse each entry |
|
||||
+-----------------------------+
|
||||
|
|
||||
v
|
||||
+-----------------------------+
|
||||
| 3. Validate manifest.json |
|
||||
| - UUID matches item |
|
||||
| - kc_version supported |
|
||||
| - revision_hash present |
|
||||
+-----------------------------+
|
||||
|
|
||||
v
|
||||
+-----------------------------+
|
||||
| 4. Index metadata |
|
||||
| - Upsert item_metadata |
|
||||
| - Upsert dependencies |
|
||||
| - Append history entry |
|
||||
| - Snapshot approvals |
|
||||
| - Register macros |
|
||||
| - Register job defs |
|
||||
+-----------------------------+
|
||||
|
|
||||
v
|
||||
+-----------------------------+
|
||||
| 5. Broadcast SSE events |
|
||||
| - revision.created |
|
||||
| - metadata.updated |
|
||||
| - bom.changed (if deps |
|
||||
| differ from previous) |
|
||||
+-----------------------------+
|
||||
|
|
||||
v
|
||||
Return 201 Created
|
||||
```
|
||||
|
||||
### 2.2 Validation Rules
|
||||
|
||||
| Check | Failure response |
|
||||
|-------|-----------------|
|
||||
| `silo/manifest.json` missing | `400 Bad Request` -- file is `.fcstd` not `.kc` |
|
||||
| `manifest.uuid` doesn't match item's UUID | `409 Conflict` -- wrong item |
|
||||
| `manifest.kc_version` > server's supported version | `422 Unprocessable` -- client newer than server |
|
||||
| `manifest.revision_hash` matches current head | `200 OK` (no-op, file unchanged) |
|
||||
| Any `silo/` JSON fails to parse | `422 Unprocessable` with path and parse error |
|
||||
|
||||
If validation fails, the blob is still stored (the user uploaded it), but no metadata indexing occurs. The item's revision is created with a `metadata_error` flag so the web UI can surface the problem.
|
||||
|
||||
### 2.3 Backward Compatibility
|
||||
|
||||
Plain `.fcstd` files (no `silo/` directory) continue to work exactly as today -- stored on disk, revision created, no metadata extraction. The pipeline short-circuits at step 2 when no `silo/` directory is found.
|
||||
|
||||
---
|
||||
|
||||
## 3. Database Schema
|
||||
|
||||
### 3.1 `item_metadata` Table
|
||||
|
||||
Stores the indexed contents of `silo/metadata.json` as structured JSONB, searchable and filterable via the existing item query endpoints.
|
||||
|
||||
```sql
|
||||
CREATE TABLE item_metadata (
|
||||
item_id UUID PRIMARY KEY REFERENCES items(id) ON DELETE CASCADE,
|
||||
schema_name TEXT,
|
||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||
lifecycle_state TEXT NOT NULL DEFAULT 'draft',
|
||||
fields JSONB NOT NULL DEFAULT '{}',
|
||||
kc_version TEXT,
|
||||
manifest_uuid UUID,
|
||||
silo_instance TEXT,
|
||||
revision_hash TEXT,
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_by TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_item_metadata_tags ON item_metadata USING GIN (tags);
|
||||
CREATE INDEX idx_item_metadata_lifecycle ON item_metadata (lifecycle_state);
|
||||
CREATE INDEX idx_item_metadata_fields ON item_metadata USING GIN (fields);
|
||||
```
|
||||
|
||||
On commit, the server upserts this row from `silo/manifest.json` and `silo/metadata.json`. The `fields` column contains the schema-driven key-value pairs exactly as they appear in the JSON.
|
||||
|
||||
### 3.2 `item_dependencies` Table
|
||||
|
||||
Stores the indexed contents of `silo/dependencies.json`. Replaces the BOM for assembly relationships that originate from the CAD model.
|
||||
|
||||
```sql
|
||||
CREATE TABLE item_dependencies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
parent_item_id UUID REFERENCES items(id) ON DELETE CASCADE,
|
||||
child_uuid UUID NOT NULL,
|
||||
child_part_number TEXT,
|
||||
child_revision INTEGER,
|
||||
quantity DECIMAL,
|
||||
label TEXT,
|
||||
relationship TEXT NOT NULL DEFAULT 'component',
|
||||
revision_number INTEGER NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_item_deps_parent ON item_dependencies (parent_item_id);
|
||||
CREATE INDEX idx_item_deps_child ON item_dependencies (child_uuid);
|
||||
```
|
||||
|
||||
This table complements the existing `relationships` table. The `relationships` table is the server-authoritative BOM (editable via the web UI and API). The `item_dependencies` table is the CAD-authoritative record extracted from the file. BOM merge (per [BOM_MERGE.md](BOM_MERGE.md)) reconciles the two.
|
||||
|
||||
### 3.3 `item_approvals` Table
|
||||
|
||||
Stores the indexed contents of `silo/approvals.json`. Server-authoritative -- the `.kc` snapshot is a read cache.
|
||||
|
||||
```sql
|
||||
CREATE TABLE item_approvals (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
item_id UUID REFERENCES items(id) ON DELETE CASCADE,
|
||||
eco_number TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'draft',
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_by TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE approval_signatures (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
approval_id UUID REFERENCES item_approvals(id) ON DELETE CASCADE,
|
||||
username TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
signed_at TIMESTAMPTZ,
|
||||
comment TEXT
|
||||
);
|
||||
```
|
||||
|
||||
These tables exist independent of `.kc` commits -- approvals are created and managed through the web UI and API. On `.kc` checkout, the current approval state is serialized into `silo/approvals.json` for offline display.
|
||||
|
||||
### 3.4 `item_macros` Table
|
||||
|
||||
Registers macros from `silo/macros/` for server-side discoverability and the future Macro Store module.
|
||||
|
||||
```sql
|
||||
CREATE TABLE item_macros (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
item_id UUID REFERENCES items(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
trigger TEXT NOT NULL DEFAULT 'manual',
|
||||
content TEXT NOT NULL,
|
||||
revision_number INTEGER NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(item_id, filename)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API Endpoints
|
||||
|
||||
These endpoints serve the viewport widgets in Create. All are under `/api/items/{partNumber}` and follow the existing auth model.
|
||||
|
||||
### 4.1 Metadata
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/metadata` | viewer | Get indexed metadata (schema fields, tags, lifecycle) |
|
||||
| `PUT` | `/metadata` | editor | Update metadata fields from client |
|
||||
| `PATCH` | `/metadata/lifecycle` | editor | Transition lifecycle state |
|
||||
| `PATCH` | `/metadata/tags` | editor | Add/remove tags |
|
||||
|
||||
**`GET /api/items/{partNumber}/metadata`**
|
||||
|
||||
Returns the indexed metadata for viewport display. This is the fast path -- reads from `item_metadata` rather than downloading and parsing the `.kc` ZIP.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_name": "mechanical-part-v2",
|
||||
"lifecycle_state": "draft",
|
||||
"tags": ["structural", "aluminum"],
|
||||
"fields": {
|
||||
"material": "6061-T6",
|
||||
"finish": "anodized",
|
||||
"weight_kg": 0.34,
|
||||
"category": "bracket"
|
||||
},
|
||||
"manifest": {
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"silo_instance": "https://silo.example.com",
|
||||
"revision_hash": "a1b2c3d4e5f6",
|
||||
"kc_version": "1.0"
|
||||
},
|
||||
"updated_at": "2026-02-13T20:30:00Z",
|
||||
"updated_by": "joseph"
|
||||
}
|
||||
```
|
||||
|
||||
**`PUT /api/items/{partNumber}/metadata`**
|
||||
|
||||
Accepts a partial update of schema fields. The server merges into the existing `fields` JSONB. This is the write-back path for the Metadata Editor widget.
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"material": "7075-T6",
|
||||
"weight_kg": 0.31
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The server validates field names against the schema descriptor. Unknown fields are rejected with `422`.
|
||||
|
||||
**`PATCH /api/items/{partNumber}/metadata/lifecycle`**
|
||||
|
||||
Transitions lifecycle state. The server validates the transition is permitted (e.g., `draft` -> `review` is allowed, `released` -> `draft` is not without admin override).
|
||||
|
||||
```json
|
||||
{ "state": "review" }
|
||||
```
|
||||
|
||||
### 4.2 Dependencies
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/dependencies` | viewer | Get CAD-extracted dependency list |
|
||||
| `GET` | `/dependencies/resolve` | viewer | Resolve UUIDs to current part numbers and file status |
|
||||
|
||||
**`GET /api/items/{partNumber}/dependencies`**
|
||||
|
||||
Returns the raw dependency list from the last `.kc` commit.
|
||||
|
||||
**`GET /api/items/{partNumber}/dependencies/resolve`**
|
||||
|
||||
Returns the dependency list with each UUID resolved to its current part number, revision, and whether the file exists on disk. This is what the Dependency Table widget calls to populate the status column.
|
||||
|
||||
```json
|
||||
{
|
||||
"links": [
|
||||
{
|
||||
"uuid": "660e8400-...",
|
||||
"part_number": "KC-BRK-0042",
|
||||
"label": "Base Plate",
|
||||
"revision": 2,
|
||||
"quantity": 1,
|
||||
"resolved": true,
|
||||
"file_available": true
|
||||
},
|
||||
{
|
||||
"uuid": "770e8400-...",
|
||||
"part_number": "KC-HDW-0108",
|
||||
"label": "M6 SHCS",
|
||||
"revision": 1,
|
||||
"quantity": 4,
|
||||
"resolved": true,
|
||||
"file_available": true
|
||||
},
|
||||
{
|
||||
"uuid": "880e8400-...",
|
||||
"part_number": null,
|
||||
"label": "Cover Panel",
|
||||
"revision": 1,
|
||||
"quantity": 1,
|
||||
"resolved": false,
|
||||
"file_available": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Approvals
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/approvals` | viewer | Get current approval state |
|
||||
| `POST` | `/approvals` | editor | Create ECO / start approval workflow |
|
||||
| `POST` | `/approvals/{id}/sign` | editor | Sign (approve/reject) |
|
||||
|
||||
These endpoints power the Approvals Viewer widget. The viewer is read-only in Create -- sign actions happen in the web UI, but the API exists for both.
|
||||
|
||||
### 4.4 Macros
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/macros` | viewer | List registered macros |
|
||||
| `GET` | `/macros/{filename}` | viewer | Get macro source |
|
||||
|
||||
Read-only server-side. Macros are authored in Create and committed inside the `.kc`. The server indexes them for discoverability in the future Macro Store.
|
||||
|
||||
### 4.5 Existing Endpoints (unchanged)
|
||||
|
||||
The viewport widgets also consume these existing endpoints:
|
||||
|
||||
| Widget | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| History Viewer | `GET /api/items/{pn}/revisions` | Full revision list |
|
||||
| History Viewer | `GET /api/items/{pn}/revisions/compare` | Property diff |
|
||||
| Job Viewer | `GET /api/jobs?item={pn}&definition={name}&limit=1` | Last job run |
|
||||
| Job Viewer | `POST /api/jobs` | Trigger job |
|
||||
| Job Viewer | `GET /api/jobs/{id}/logs` | Job log |
|
||||
| Manifest Viewer | `GET /api/items/{pn}` | Item details (UUID, etc.) |
|
||||
|
||||
No changes needed to these -- they already exist and return the data the widgets need.
|
||||
|
||||
---
|
||||
|
||||
## 5. Checkout Pipeline
|
||||
|
||||
When a client downloads a `.kc` via `GET /api/items/{partNumber}/file`, the server packs current server-side state into the `silo/` directory before serving the file. This ensures the client always gets the latest metadata, even if it was edited via the web UI since the last commit.
|
||||
|
||||
### 5.1 Pipeline Steps
|
||||
|
||||
```
|
||||
Client requests file download
|
||||
|
|
||||
v
|
||||
+-----------------------------+
|
||||
| 1. Read .kc from disk |
|
||||
+-----------------------------+
|
||||
|
|
||||
v
|
||||
+-----------------------------+
|
||||
| 2. Pack silo/ from DB |
|
||||
| - manifest.json (item) |
|
||||
| - metadata.json (index) |
|
||||
| - history.json (revs) |
|
||||
| - approvals.json (ECO) |
|
||||
| - dependencies.json |
|
||||
| - macros/ (index) |
|
||||
| - jobs/ (job defs) |
|
||||
+-----------------------------+
|
||||
|
|
||||
v
|
||||
+-----------------------------+
|
||||
| 3. Replace silo/ in ZIP |
|
||||
| Remove old entries |
|
||||
| Write packed entries |
|
||||
+-----------------------------+
|
||||
|
|
||||
v
|
||||
Stream .kc to client
|
||||
```
|
||||
|
||||
### 5.2 Packing Rules
|
||||
|
||||
| `silo/` entry | Source | Notes |
|
||||
|---------------|--------|-------|
|
||||
| `manifest.json` | `item_metadata` + `items` table | UUID from item, revision_hash from latest revision |
|
||||
| `metadata.json` | `item_metadata.fields` + tags + lifecycle | Serialized from indexed columns |
|
||||
| `history.json` | `revisions` table | Last 20 revisions for this item |
|
||||
| `approvals.json` | `item_approvals` + `approval_signatures` | Current ECO state, omitted if no active ECO |
|
||||
| `dependencies.json` | `item_dependencies` | Current revision's dependency list |
|
||||
| `macros/*.py` | `item_macros` | All registered macros |
|
||||
| `jobs/*.yaml` | `job_definitions` filtered by item type | Job definitions matching this item's trigger filters |
|
||||
|
||||
### 5.3 Caching
|
||||
|
||||
Packing the `silo/` directory on every download has a cost. To mitigate:
|
||||
|
||||
- **ETag header**: The response includes an ETag computed from the revision number + metadata `updated_at`. If the client sends `If-None-Match`, the server can return `304 Not Modified`.
|
||||
- **Lazy packing**: If the `.kc` blob's `silo/manifest.json` revision_hash matches the current head *and* `item_metadata.updated_at` is older than the blob's upload time, skip repacking entirely -- the blob is already current.
|
||||
|
||||
---
|
||||
|
||||
## 6. SSE Events
|
||||
|
||||
The viewport widgets subscribe to SSE for live updates. These events are broadcast when server-side metadata changes, whether via `.kc` commit, web UI edit, or API call.
|
||||
|
||||
| Event | Payload | Trigger |
|
||||
|-------|---------|---------|
|
||||
| `metadata.updated` | `{part_number, changed_fields[], lifecycle_state, updated_by}` | Metadata PUT/PATCH |
|
||||
| `metadata.lifecycle` | `{part_number, from_state, to_state, updated_by}` | Lifecycle transition |
|
||||
| `metadata.tags` | `{part_number, added[], removed[]}` | Tag add/remove |
|
||||
| `approval.created` | `{part_number, eco_number, state}` | ECO created |
|
||||
| `approval.signed` | `{part_number, eco_number, user, role, status}` | Approver action |
|
||||
| `approval.completed` | `{part_number, eco_number, final_state}` | All approvers acted |
|
||||
| `dependencies.changed` | `{part_number, added[], removed[], changed[]}` | Dependency diff on commit |
|
||||
|
||||
Existing events (`revision.created`, `job.*`, `bom.changed`) continue to work as documented in [SPECIFICATION.md](SPECIFICATION.md) and [WORKERS.md](WORKERS.md).
|
||||
|
||||
### 6.1 Widget Subscription Map
|
||||
|
||||
| Viewport widget | Subscribes to |
|
||||
|-----------------|---------------|
|
||||
| Manifest Viewer | -- (read-only, no live updates) |
|
||||
| Metadata Editor | `metadata.updated`, `metadata.lifecycle`, `metadata.tags` |
|
||||
| History Viewer | `revision.created` |
|
||||
| Approvals Viewer | `approval.created`, `approval.signed`, `approval.completed` |
|
||||
| Dependency Table | `dependencies.changed` |
|
||||
| Job Viewer | `job.created`, `job.progress`, `job.completed`, `job.failed` |
|
||||
| Macro Editor | -- (local-only until committed) |
|
||||
|
||||
---
|
||||
|
||||
## 7. Web UI Integration
|
||||
|
||||
The Silo web UI also benefits from indexed metadata. These are additions to existing pages, not new pages.
|
||||
|
||||
### 7.1 Items Page
|
||||
|
||||
The item detail panel gains a **Metadata** tab (alongside Main, Properties, Revisions, BOM, Where Used) showing the schema-driven form from `GET /api/items/{pn}/metadata`. Editable for editors.
|
||||
|
||||
### 7.2 Items List
|
||||
|
||||
New filterable columns: `lifecycle_state`, `tags`. The existing search endpoint gains metadata-aware filtering:
|
||||
|
||||
```
|
||||
GET /api/items?lifecycle=released&tag=aluminum
|
||||
GET /api/items/search?q=bracket&lifecycle=draft
|
||||
```
|
||||
|
||||
### 7.3 Approvals Page
|
||||
|
||||
A new page accessible from the top navigation (visible when a future `approvals` module is enabled). Lists all active ECOs with their approval progress.
|
||||
|
||||
---
|
||||
|
||||
## 8. Migration
|
||||
|
||||
### 8.1 Database Migration
|
||||
|
||||
A single migration adds the `item_metadata`, `item_dependencies`, `item_approvals`, `approval_signatures`, and `item_macros` tables. Existing items have no metadata rows -- they're created on first `.kc` commit or via `PUT /api/items/{pn}/metadata`.
|
||||
|
||||
### 8.2 Backfill
|
||||
|
||||
For items that already have `.kc` files stored on disk (committed before this feature), an admin endpoint re-runs the extraction pipeline:
|
||||
|
||||
```
|
||||
POST /api/admin/reindex-metadata
|
||||
```
|
||||
|
||||
This iterates all items with `.kc` files, opens each ZIP, and indexes the `silo/` contents. Idempotent -- safe to run multiple times.
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation Order
|
||||
|
||||
| Phase | Server work | Supports client phase |
|
||||
|-------|------------|----------------------|
|
||||
| 1 | `item_metadata` table + `GET/PUT /metadata` + commit extraction | SILO_VIEWPORT Phase 1-2 (Manifest, Metadata) |
|
||||
| 2 | Pack `silo/` on checkout + ETag caching | SILO_VIEWPORT Phase 1-3 |
|
||||
| 3 | `item_dependencies` table + `/dependencies/resolve` | SILO_VIEWPORT Phase 5 (Dependency Table) |
|
||||
| 4 | `item_macros` table + `/macros` endpoints | SILO_VIEWPORT Phase 6 (Macro Editor) |
|
||||
| 5 | `item_approvals` tables + `/approvals` endpoints | SILO_VIEWPORT Phase 7 (Approvals Viewer) |
|
||||
| 6 | SSE events for metadata/approvals/dependencies | SILO_VIEWPORT Phase 8 (Live integration) |
|
||||
| 7 | Web UI metadata tab + list filters | Independent of client |
|
||||
|
||||
Phases 1-2 are prerequisite for the viewport to work with live data. Phases 3-6 can be built in parallel with client widget development. Phase 7 is web-UI-only and independent.
|
||||
|
||||
---
|
||||
|
||||
## 10. References
|
||||
|
||||
- [SILO_VIEWPORT.md](SILO_VIEWPORT.md) -- Client-side viewport widget specification
|
||||
- [KC_SPECIFICATION.md](KC_SPECIFICATION.md) -- .kc file format specification
|
||||
- [SPECIFICATION.md](SPECIFICATION.md) -- Silo server API reference
|
||||
- [BOM_MERGE.md](BOM_MERGE.md) -- BOM merge rules (dependency reconciliation)
|
||||
- [WORKERS.md](WORKERS.md) -- Job queue (job viewer data source)
|
||||
- [MODULES.md](MODULES.md) -- Module system (approval module gating)
|
||||
- [ROADMAP.md](ROADMAP.md) -- Platform roadmap tiers
|
||||
@@ -23,7 +23,7 @@ These cannot be disabled. They define what Silo *is*.
|
||||
|-----------|------|-------------|
|
||||
| `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 | MinIO/S3 file storage, presigned uploads, versioning |
|
||||
| `storage` | Storage | Filesystem storage |
|
||||
|
||||
### 2.2 Optional Modules
|
||||
|
||||
@@ -470,12 +470,10 @@ Returns full config grouped by module with secrets redacted:
|
||||
"default": "kindred-rd"
|
||||
},
|
||||
"storage": {
|
||||
"endpoint": "minio:9000",
|
||||
"bucket": "silo-files",
|
||||
"access_key": "****",
|
||||
"secret_key": "****",
|
||||
"use_ssl": false,
|
||||
"region": "us-east-1",
|
||||
"backend": "filesystem",
|
||||
"filesystem": {
|
||||
"root_dir": "/var/lib/silo/data"
|
||||
},
|
||||
"status": "connected"
|
||||
},
|
||||
"database": {
|
||||
@@ -566,7 +564,7 @@ Available for modules with external connections:
|
||||
|
||||
| Module | Test Action |
|
||||
|--------|------------|
|
||||
| `storage` | Ping MinIO, verify bucket exists |
|
||||
| `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 |
|
||||
@@ -602,11 +600,9 @@ database:
|
||||
sslmode: disable
|
||||
|
||||
storage:
|
||||
endpoint: minio:9000
|
||||
bucket: silo-files
|
||||
access_key: silominio
|
||||
secret_key: silominiosecret
|
||||
use_ssl: false
|
||||
backend: filesystem
|
||||
filesystem:
|
||||
root_dir: /var/lib/silo/data
|
||||
|
||||
schemas:
|
||||
directory: /etc/silo/schemas
|
||||
|
||||
@@ -337,7 +337,7 @@ Supporting files:
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `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/hooks/useFormDescriptor.ts` | Fetches and caches form descriptor from `/api/schemas/{name}/form` |
|
||||
| `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
|
||||
|
||||
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**:
|
||||
|
||||
@@ -435,7 +435,7 @@ interface FileDropZoneProps {
|
||||
|
||||
interface PendingAttachment {
|
||||
file: File;
|
||||
objectKey: string; // MinIO key after upload
|
||||
objectKey: string; // storage key after upload
|
||||
uploadProgress: number; // 0-100
|
||||
uploadStatus: 'pending' | 'uploading' | 'complete' | 'error';
|
||||
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 }`.
|
||||
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`.
|
||||
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
|
||||
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
|
||||
|
||||
@@ -612,7 +612,7 @@ Request: { "object_key": "uploads/tmp/{uuid}/thumb.png" }
|
||||
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)
|
||||
|
||||
|
||||
13
go.mod
13
go.mod
@@ -11,7 +11,6 @@ require (
|
||||
github.com/go-ldap/ldap/v3 v3.4.12
|
||||
github.com/google/uuid v1.6.0
|
||||
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/sahilm/fuzzy v0.1.1
|
||||
golang.org/x/crypto v0.47.0
|
||||
@@ -21,28 +20,16 @@ require (
|
||||
|
||||
require (
|
||||
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-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // 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/mattn/go-colorable v0.1.13 // 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/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/sys v0.40.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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
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/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
||||
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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
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/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/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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
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.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
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/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
|
||||
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/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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
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-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-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-20220811171246-fbc7d0a398ab/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/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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
|
||||
modules.NewRegistry(), // modules
|
||||
nil, // cfg
|
||||
nil, // workflows
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ func newTestServer(t *testing.T) *Server {
|
||||
"", // jobDefsDir
|
||||
modules.NewRegistry(), // modules
|
||||
nil, // cfg
|
||||
nil, // workflows
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,34 @@ type Event struct {
|
||||
|
||||
// sseClient represents a single connected SSE consumer.
|
||||
type sseClient struct {
|
||||
ch chan Event
|
||||
closed chan struct{}
|
||||
ch chan Event
|
||||
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 (
|
||||
@@ -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.
|
||||
func (b *Broker) Subscribe() *sseClient {
|
||||
func (b *Broker) Subscribe(userID, workstationID string) *sseClient {
|
||||
c := &sseClient{
|
||||
ch: make(chan Event, clientChanSize),
|
||||
closed: make(chan struct{}),
|
||||
ch: make(chan Event, clientChanSize),
|
||||
closed: make(chan struct{}),
|
||||
userID: userID,
|
||||
workstationID: workstationID,
|
||||
itemFilters: make(map[string]struct{}),
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.clients[c] = struct{}{}
|
||||
@@ -106,6 +135,49 @@ func (b *Broker) Publish(eventType string, data string) {
|
||||
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.
|
||||
func (b *Broker) ClientCount() int {
|
||||
b.mu.RLock()
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
func TestBrokerSubscribeUnsubscribe(t *testing.T) {
|
||||
b := NewBroker(zerolog.Nop())
|
||||
|
||||
c := b.Subscribe()
|
||||
c := b.Subscribe("", "")
|
||||
if b.ClientCount() != 1 {
|
||||
t.Fatalf("expected 1 client, got %d", b.ClientCount())
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func TestBrokerSubscribeUnsubscribe(t *testing.T) {
|
||||
|
||||
func TestBrokerPublish(t *testing.T) {
|
||||
b := NewBroker(zerolog.Nop())
|
||||
c := b.Subscribe()
|
||||
c := b.Subscribe("", "")
|
||||
defer b.Unsubscribe(c)
|
||||
|
||||
b.Publish("item.created", `{"part_number":"F01-0001"}`)
|
||||
@@ -46,7 +46,7 @@ func TestBrokerPublish(t *testing.T) {
|
||||
|
||||
func TestBrokerPublishDropsSlow(t *testing.T) {
|
||||
b := NewBroker(zerolog.Nop())
|
||||
c := b.Subscribe()
|
||||
c := b.Subscribe("", "")
|
||||
defer b.Unsubscribe(c)
|
||||
|
||||
// Fill the client's channel
|
||||
@@ -89,9 +89,9 @@ func TestBrokerEventsSince(t *testing.T) {
|
||||
func TestBrokerClientCount(t *testing.T) {
|
||||
b := NewBroker(zerolog.Nop())
|
||||
|
||||
c1 := b.Subscribe()
|
||||
c2 := b.Subscribe()
|
||||
c3 := b.Subscribe()
|
||||
c1 := b.Subscribe("", "")
|
||||
c2 := b.Subscribe("", "")
|
||||
c3 := b.Subscribe("", "")
|
||||
|
||||
if b.ClientCount() != 3 {
|
||||
t.Fatalf("expected 3 clients, got %d", b.ClientCount())
|
||||
@@ -111,7 +111,7 @@ func TestBrokerClientCount(t *testing.T) {
|
||||
|
||||
func TestBrokerShutdown(t *testing.T) {
|
||||
b := NewBroker(zerolog.Nop())
|
||||
c := b.Subscribe()
|
||||
c := b.Subscribe("", "")
|
||||
|
||||
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
|
||||
modules.NewRegistry(), // modules
|
||||
nil, // cfg
|
||||
nil, // workflows
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ func newDAGTestServer(t *testing.T) *Server {
|
||||
broker, state,
|
||||
nil, "",
|
||||
modules.NewRegistry(), nil,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ type presignUploadRequest struct {
|
||||
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) {
|
||||
if s.storage == nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// storageBackend returns the configured storage backend name, defaulting to "minio".
|
||||
// storageBackend returns the configured storage backend name.
|
||||
func (s *Server) storageBackend() string {
|
||||
if s.cfg != nil && s.cfg.Storage.Backend != "" {
|
||||
return s.cfg.Storage.Backend
|
||||
}
|
||||
return "minio"
|
||||
return "filesystem"
|
||||
}
|
||||
|
||||
// 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/schema"
|
||||
"github.com/kindredsystems/silo/internal/storage"
|
||||
"github.com/kindredsystems/silo/internal/workflow"
|
||||
"github.com/rs/zerolog"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -58,6 +59,11 @@ type Server struct {
|
||||
metadata *db.ItemMetadataRepository
|
||||
deps *db.ItemDependencyRepository
|
||||
macros *db.ItemMacroRepository
|
||||
approvals *db.ItemApprovalRepository
|
||||
workflows map[string]*workflow.Workflow
|
||||
solverResults *db.SolverResultRepository
|
||||
workstations *db.WorkstationRepository
|
||||
editSessions *db.EditSessionRepository
|
||||
}
|
||||
|
||||
// NewServer creates a new API server.
|
||||
@@ -77,6 +83,7 @@ func NewServer(
|
||||
jobDefsDir string,
|
||||
registry *modules.Registry,
|
||||
cfg *config.Config,
|
||||
workflows map[string]*workflow.Workflow,
|
||||
) *Server {
|
||||
items := db.NewItemRepository(database)
|
||||
projects := db.NewProjectRepository(database)
|
||||
@@ -89,6 +96,10 @@ func NewServer(
|
||||
metadata := db.NewItemMetadataRepository(database)
|
||||
itemDeps := db.NewItemDependencyRepository(database)
|
||||
itemMacros := db.NewItemMacroRepository(database)
|
||||
itemApprovals := db.NewItemApprovalRepository(database)
|
||||
solverResults := db.NewSolverResultRepository(database)
|
||||
workstations := db.NewWorkstationRepository(database)
|
||||
editSessions := db.NewEditSessionRepository(database)
|
||||
seqStore := &dbSequenceStore{db: database, schemas: schemas}
|
||||
partgen := partnum.NewGenerator(schemas, seqStore)
|
||||
|
||||
@@ -120,6 +131,11 @@ func NewServer(
|
||||
metadata: metadata,
|
||||
deps: itemDeps,
|
||||
macros: itemMacros,
|
||||
approvals: itemApprovals,
|
||||
workflows: workflows,
|
||||
solverResults: solverResults,
|
||||
workstations: workstations,
|
||||
editSessions: editSessions,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ func newJobTestServer(t *testing.T) *Server {
|
||||
broker, state,
|
||||
nil, "",
|
||||
modules.NewRegistry(), nil,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -74,11 +74,58 @@ func (s *Server) packKCFile(ctx context.Context, data []byte, item *db.Item, rev
|
||||
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{
|
||||
Manifest: manifest,
|
||||
Metadata: metadata,
|
||||
History: history,
|
||||
Dependencies: deps,
|
||||
Approvals: approvals,
|
||||
}
|
||||
|
||||
return kc.Pack(data, input)
|
||||
|
||||
@@ -68,6 +68,23 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
||||
// SSE event stream (viewer+)
|
||||
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)
|
||||
})
|
||||
|
||||
// Edit sessions — current user's active sessions (gated by sessions module)
|
||||
r.Route("/edit-sessions", func(r chi.Router) {
|
||||
r.Use(server.RequireModule("sessions"))
|
||||
r.Get("/", server.HandleListUserEditSessions)
|
||||
})
|
||||
|
||||
// Auth endpoints
|
||||
r.Get("/auth/me", server.HandleGetCurrentUser)
|
||||
r.Route("/auth/tokens", func(r chi.Router) {
|
||||
@@ -177,6 +194,8 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
||||
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)
|
||||
r.Route("/dag", func(r chi.Router) {
|
||||
@@ -193,6 +212,19 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
||||
})
|
||||
})
|
||||
|
||||
// Edit sessions (gated by sessions module)
|
||||
r.Route("/edit-sessions", func(r chi.Router) {
|
||||
r.Use(server.RequireModule("sessions"))
|
||||
r.Get("/", server.HandleListItemEditSessions)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.RequireWritable)
|
||||
r.Use(server.RequireRole(auth.RoleEditor))
|
||||
r.Post("/", server.HandleAcquireEditSession)
|
||||
r.Delete("/{sessionID}", server.HandleReleaseEditSession)
|
||||
})
|
||||
})
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.RequireWritable)
|
||||
r.Use(server.RequireRole(auth.RoleEditor))
|
||||
@@ -217,6 +249,8 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
||||
r.Put("/metadata", server.HandleUpdateMetadata)
|
||||
r.Patch("/metadata/lifecycle", server.HandleUpdateLifecycle)
|
||||
r.Patch("/metadata/tags", server.HandleUpdateTags)
|
||||
r.Post("/approvals", server.HandleCreateApproval)
|
||||
r.Post("/approvals/{id}/sign", server.HandleSignApproval)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -244,6 +278,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)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.RequireWritable)
|
||||
|
||||
@@ -142,6 +142,9 @@ func (s *Server) HandleRunnerCompleteJob(w http.ResponseWriter, r *http.Request)
|
||||
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{
|
||||
"job_id": jobID,
|
||||
"runner_id": runner.ID,
|
||||
|
||||
@@ -86,7 +86,7 @@ func (ss *ServerState) ToggleReadOnly() {
|
||||
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.
|
||||
func (ss *ServerState) StartStorageHealthCheck() {
|
||||
if ss.storage == nil {
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestServerStateToggleReadOnly(t *testing.T) {
|
||||
|
||||
func TestServerStateBroadcastsOnTransition(t *testing.T) {
|
||||
b := NewBroker(zerolog.Nop())
|
||||
c := b.Subscribe()
|
||||
c := b.Subscribe("", "")
|
||||
defer b.Unsubscribe(c)
|
||||
|
||||
ss := NewServerState(zerolog.Nop(), nil, b)
|
||||
|
||||
293
internal/api/session_handlers.go
Normal file
293
internal/api/session_handlers.go
Normal file
@@ -0,0 +1,293 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/kindredsystems/silo/internal/auth"
|
||||
"github.com/kindredsystems/silo/internal/db"
|
||||
"github.com/kindredsystems/silo/internal/modules"
|
||||
)
|
||||
|
||||
var validContextLevels = map[string]bool{
|
||||
"sketch": true,
|
||||
"partdesign": true,
|
||||
"assembly": true,
|
||||
}
|
||||
|
||||
type editSessionResponse struct {
|
||||
ID string `json:"id"`
|
||||
ItemID string `json:"item_id"`
|
||||
PartNumber string `json:"part_number,omitempty"`
|
||||
UserID string `json:"user_id"`
|
||||
WorkstationID string `json:"workstation_id"`
|
||||
ContextLevel string `json:"context_level"`
|
||||
ObjectID *string `json:"object_id"`
|
||||
DependCone []string `json:"dependency_cone"`
|
||||
AcquiredAt string `json:"acquired_at"`
|
||||
LastHeartbeat string `json:"last_heartbeat"`
|
||||
}
|
||||
|
||||
func sessionToResponse(s *db.EditSession, partNumber string) editSessionResponse {
|
||||
cone := s.DependencyCone
|
||||
if cone == nil {
|
||||
cone = []string{}
|
||||
}
|
||||
return editSessionResponse{
|
||||
ID: s.ID,
|
||||
ItemID: s.ItemID,
|
||||
PartNumber: partNumber,
|
||||
UserID: s.UserID,
|
||||
WorkstationID: s.WorkstationID,
|
||||
ContextLevel: s.ContextLevel,
|
||||
ObjectID: s.ObjectID,
|
||||
DependCone: cone,
|
||||
AcquiredAt: s.AcquiredAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
LastHeartbeat: s.LastHeartbeat.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAcquireEditSession acquires an edit session on an item.
|
||||
// POST /api/items/{partNumber}/edit-sessions
|
||||
func (s *Server) HandleAcquireEditSession(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
partNumber := chi.URLParam(r, "partNumber")
|
||||
|
||||
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to get item")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
||||
return
|
||||
}
|
||||
if item == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
WorkstationID string `json:"workstation_id"`
|
||||
ContextLevel string `json:"context_level"`
|
||||
ObjectID *string `json:"object_id"`
|
||||
DependencyCone []string `json:"dependency_cone"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
if req.WorkstationID == "" {
|
||||
writeError(w, http.StatusBadRequest, "validation_error", "workstation_id is required")
|
||||
return
|
||||
}
|
||||
if !validContextLevels[req.ContextLevel] {
|
||||
writeError(w, http.StatusBadRequest, "validation_error", "context_level must be sketch, partdesign, or assembly")
|
||||
return
|
||||
}
|
||||
|
||||
// If no dependency cone provided and DAG module is enabled, attempt to compute it.
|
||||
depCone := req.DependencyCone
|
||||
if len(depCone) == 0 && req.ObjectID != nil && s.modules.IsEnabled(modules.DAG) {
|
||||
node, nodeErr := s.dag.GetNodeByKey(ctx, item.ID, item.CurrentRevision, *req.ObjectID)
|
||||
if nodeErr == nil && node != nil {
|
||||
coneNodes, coneErr := s.dag.GetForwardCone(ctx, node.ID)
|
||||
if coneErr == nil {
|
||||
depCone = make([]string, len(coneNodes))
|
||||
for i, n := range coneNodes {
|
||||
depCone[i] = n.NodeKey
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session := &db.EditSession{
|
||||
ItemID: item.ID,
|
||||
UserID: user.ID,
|
||||
WorkstationID: req.WorkstationID,
|
||||
ContextLevel: req.ContextLevel,
|
||||
ObjectID: req.ObjectID,
|
||||
DependencyCone: depCone,
|
||||
}
|
||||
|
||||
if err := s.editSessions.Acquire(ctx, session); err != nil {
|
||||
// Check for unique constraint violation (hard interference).
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
s.writeConflictResponse(w, r, item.ID, req.ContextLevel, req.ObjectID)
|
||||
return
|
||||
}
|
||||
s.logger.Error().Err(err).Msg("failed to acquire edit session")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to acquire edit session")
|
||||
return
|
||||
}
|
||||
|
||||
s.broker.PublishToItem(item.ID, "edit.session_acquired", mustMarshal(map[string]any{
|
||||
"session_id": session.ID,
|
||||
"item_id": item.ID,
|
||||
"part_number": partNumber,
|
||||
"user": user.Username,
|
||||
"workstation": req.WorkstationID,
|
||||
"context_level": session.ContextLevel,
|
||||
"object_id": session.ObjectID,
|
||||
}))
|
||||
|
||||
writeJSON(w, http.StatusOK, sessionToResponse(session, partNumber))
|
||||
}
|
||||
|
||||
// writeConflictResponse builds a 409 response with holder info.
|
||||
func (s *Server) writeConflictResponse(w http.ResponseWriter, r *http.Request, itemID, contextLevel string, objectID *string) {
|
||||
ctx := r.Context()
|
||||
conflict, err := s.editSessions.GetConflict(ctx, itemID, contextLevel, objectID)
|
||||
if err != nil || conflict == nil {
|
||||
writeError(w, http.StatusConflict, "hard_interference", "Another user is editing this object")
|
||||
return
|
||||
}
|
||||
|
||||
// Look up holder's username and workstation name.
|
||||
holderUser := "unknown"
|
||||
if u, err := s.auth.GetUserByID(ctx, conflict.UserID); err == nil && u != nil {
|
||||
holderUser = u.Username
|
||||
}
|
||||
holderWS := conflict.WorkstationID
|
||||
if ws, err := s.workstations.GetByID(ctx, conflict.WorkstationID); err == nil && ws != nil {
|
||||
holderWS = ws.Name
|
||||
}
|
||||
|
||||
objDesc := contextLevel
|
||||
if objectID != nil {
|
||||
objDesc = *objectID
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"error": "hard_interference",
|
||||
"holder": map[string]any{
|
||||
"user": holderUser,
|
||||
"workstation": holderWS,
|
||||
"context_level": conflict.ContextLevel,
|
||||
"object_id": conflict.ObjectID,
|
||||
"acquired_at": conflict.AcquiredAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
},
|
||||
"message": fmt.Sprintf("%s is currently editing %s", holderUser, objDesc),
|
||||
})
|
||||
}
|
||||
|
||||
// HandleReleaseEditSession releases an edit session.
|
||||
// DELETE /api/items/{partNumber}/edit-sessions/{sessionID}
|
||||
func (s *Server) HandleReleaseEditSession(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
partNumber := chi.URLParam(r, "partNumber")
|
||||
sessionID := chi.URLParam(r, "sessionID")
|
||||
|
||||
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to get item")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
||||
return
|
||||
}
|
||||
if item == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
session, err := s.editSessions.GetByID(ctx, sessionID)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Str("session_id", sessionID).Msg("failed to get edit session")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get edit session")
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Edit session not found")
|
||||
return
|
||||
}
|
||||
|
||||
if session.UserID != user.ID && user.Role != auth.RoleAdmin {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "You can only release your own edit sessions")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.editSessions.Release(ctx, sessionID); err != nil {
|
||||
s.logger.Error().Err(err).Str("session_id", sessionID).Msg("failed to release edit session")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to release edit session")
|
||||
return
|
||||
}
|
||||
|
||||
s.broker.PublishToItem(item.ID, "edit.session_released", mustMarshal(map[string]any{
|
||||
"session_id": session.ID,
|
||||
"item_id": item.ID,
|
||||
"part_number": partNumber,
|
||||
"user": user.Username,
|
||||
"context_level": session.ContextLevel,
|
||||
"object_id": session.ObjectID,
|
||||
}))
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleListItemEditSessions lists active edit sessions for an item.
|
||||
// GET /api/items/{partNumber}/edit-sessions
|
||||
func (s *Server) HandleListItemEditSessions(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
partNumber := chi.URLParam(r, "partNumber")
|
||||
|
||||
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to get item")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
||||
return
|
||||
}
|
||||
if item == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := s.editSessions.ListForItem(ctx, item.ID)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to list edit sessions")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list edit sessions")
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]editSessionResponse, len(sessions))
|
||||
for i, sess := range sessions {
|
||||
out[i] = sessionToResponse(sess, partNumber)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// HandleListUserEditSessions lists active edit sessions for the current user.
|
||||
// GET /api/edit-sessions
|
||||
func (s *Server) HandleListUserEditSessions(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := s.editSessions.ListForUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to list edit sessions")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list edit sessions")
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]editSessionResponse, len(sessions))
|
||||
for i, sess := range sessions {
|
||||
out[i] = sessionToResponse(sess, "")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -224,10 +224,8 @@ func (s *Server) buildSchemasSettings() map[string]any {
|
||||
func (s *Server) buildStorageSettings(ctx context.Context) map[string]any {
|
||||
result := map[string]any{
|
||||
"enabled": true,
|
||||
"endpoint": s.cfg.Storage.Endpoint,
|
||||
"bucket": s.cfg.Storage.Bucket,
|
||||
"use_ssl": s.cfg.Storage.UseSSL,
|
||||
"region": s.cfg.Storage.Region,
|
||||
"backend": "filesystem",
|
||||
"root_dir": s.cfg.Storage.Filesystem.RootDir,
|
||||
}
|
||||
if s.storage != nil {
|
||||
if err := s.storage.Ping(ctx); err != nil {
|
||||
|
||||
@@ -31,8 +31,8 @@ func newSettingsTestServer(t *testing.T) *Server {
|
||||
MaxConnections: 10,
|
||||
},
|
||||
Storage: config.StorageConfig{
|
||||
Endpoint: "minio:9000", Bucket: "silo", Region: "us-east-1",
|
||||
AccessKey: "minioadmin", SecretKey: "miniosecret",
|
||||
Backend: "filesystem",
|
||||
Filesystem: config.FilesystemConfig{RootDir: "/tmp/silo-test"},
|
||||
},
|
||||
Schemas: config.SchemasConfig{Directory: "/etc/silo/schemas", Default: "kindred-rd"},
|
||||
Auth: config.AuthConfig{
|
||||
@@ -61,6 +61,7 @@ func newSettingsTestServer(t *testing.T) *Server {
|
||||
"", // jobDefsDir
|
||||
modules.NewRegistry(), // modules
|
||||
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"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/kindredsystems/silo/internal/auth"
|
||||
)
|
||||
|
||||
// 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("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)
|
||||
|
||||
if wsID != "" {
|
||||
s.workstations.Touch(r.Context(), wsID)
|
||||
}
|
||||
|
||||
// Replay missed events if Last-Event-ID is present.
|
||||
if lastIDStr := r.Header.Get("Last-Event-ID"); lastIDStr != "" {
|
||||
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.
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Storage StorageConfig `yaml:"storage"`
|
||||
Schemas SchemasConfig `yaml:"schemas"`
|
||||
FreeCAD FreeCADConfig `yaml:"freecad"`
|
||||
Odoo OdooConfig `yaml:"odoo"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
Jobs JobsConfig `yaml:"jobs"`
|
||||
Modules ModulesConfig `yaml:"modules"`
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Storage StorageConfig `yaml:"storage"`
|
||||
Schemas SchemasConfig `yaml:"schemas"`
|
||||
FreeCAD FreeCADConfig `yaml:"freecad"`
|
||||
Odoo OdooConfig `yaml:"odoo"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
Jobs JobsConfig `yaml:"jobs"`
|
||||
Workflows WorkflowsConfig `yaml:"workflows"`
|
||||
Solver SolverConfig `yaml:"solver"`
|
||||
Modules ModulesConfig `yaml:"modules"`
|
||||
}
|
||||
|
||||
// ModulesConfig holds explicit enable/disable toggles for optional modules.
|
||||
@@ -31,6 +33,8 @@ type ModulesConfig struct {
|
||||
FreeCAD *ModuleToggle `yaml:"freecad"`
|
||||
Jobs *ModuleToggle `yaml:"jobs"`
|
||||
DAG *ModuleToggle `yaml:"dag"`
|
||||
Solver *ModuleToggle `yaml:"solver"`
|
||||
Sessions *ModuleToggle `yaml:"sessions"`
|
||||
}
|
||||
|
||||
// ModuleToggle holds an optional enabled flag. The pointer allows
|
||||
@@ -109,15 +113,9 @@ type DatabaseConfig struct {
|
||||
MaxConnections int `yaml:"max_connections"`
|
||||
}
|
||||
|
||||
// StorageConfig holds object storage settings.
|
||||
// StorageConfig holds file storage settings.
|
||||
type StorageConfig struct {
|
||||
Backend string `yaml:"backend"` // "minio" (default) or "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"`
|
||||
Backend string `yaml:"backend"` // "filesystem"
|
||||
Filesystem FilesystemConfig `yaml:"filesystem"`
|
||||
}
|
||||
|
||||
@@ -146,6 +144,19 @@ type JobsConfig struct {
|
||||
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.
|
||||
type OdooConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
@@ -183,9 +194,6 @@ func Load(path string) (*Config, error) {
|
||||
if cfg.Database.MaxConnections == 0 {
|
||||
cfg.Database.MaxConnections = 10
|
||||
}
|
||||
if cfg.Storage.Region == "" {
|
||||
cfg.Storage.Region = "us-east-1"
|
||||
}
|
||||
if cfg.Schemas.Directory == "" {
|
||||
cfg.Schemas.Directory = "/etc/silo/schemas"
|
||||
}
|
||||
@@ -204,6 +212,15 @@ func Load(path string) (*Config, error) {
|
||||
if cfg.Jobs.DefaultPriority == 0 {
|
||||
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
|
||||
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 != "" {
|
||||
cfg.Database.Password = v
|
||||
}
|
||||
if v := os.Getenv("SILO_MINIO_ENDPOINT"); v != "" {
|
||||
cfg.Storage.Endpoint = v
|
||||
if v := os.Getenv("SILO_STORAGE_ROOT_DIR"); v != "" {
|
||||
cfg.Storage.Filesystem.RootDir = v
|
||||
}
|
||||
if v := os.Getenv("SILO_MINIO_ACCESS_KEY"); v != "" {
|
||||
cfg.Storage.AccessKey = v
|
||||
}
|
||||
if v := os.Getenv("SILO_MINIO_SECRET_KEY"); v != "" {
|
||||
cfg.Storage.SecretKey = v
|
||||
if v := os.Getenv("SILO_SOLVER_DEFAULT"); v != "" {
|
||||
cfg.Solver.DefaultSolver = v
|
||||
}
|
||||
|
||||
// Auth defaults
|
||||
|
||||
222
internal/db/edit_sessions.go
Normal file
222
internal/db/edit_sessions.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// EditSession represents an active editing context.
|
||||
type EditSession struct {
|
||||
ID string
|
||||
ItemID string
|
||||
UserID string
|
||||
WorkstationID string
|
||||
ContextLevel string
|
||||
ObjectID *string
|
||||
DependencyCone []string
|
||||
AcquiredAt time.Time
|
||||
LastHeartbeat time.Time
|
||||
}
|
||||
|
||||
// EditSessionRepository provides edit session database operations.
|
||||
type EditSessionRepository struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewEditSessionRepository creates a new edit session repository.
|
||||
func NewEditSessionRepository(db *DB) *EditSessionRepository {
|
||||
return &EditSessionRepository{db: db}
|
||||
}
|
||||
|
||||
// Acquire inserts a new edit session. Returns a unique constraint error
|
||||
// if another session already holds the same (item_id, context_level, object_id).
|
||||
func (r *EditSessionRepository) Acquire(ctx context.Context, s *EditSession) error {
|
||||
return r.db.pool.QueryRow(ctx, `
|
||||
INSERT INTO edit_sessions (item_id, user_id, workstation_id, context_level, object_id, dependency_cone)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, acquired_at, last_heartbeat
|
||||
`, s.ItemID, s.UserID, s.WorkstationID, s.ContextLevel, s.ObjectID, s.DependencyCone).
|
||||
Scan(&s.ID, &s.AcquiredAt, &s.LastHeartbeat)
|
||||
}
|
||||
|
||||
// Release deletes an edit session by ID.
|
||||
func (r *EditSessionRepository) Release(ctx context.Context, id string) error {
|
||||
_, err := r.db.pool.Exec(ctx, `DELETE FROM edit_sessions WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReleaseForWorkstation deletes all sessions for a workstation, returning
|
||||
// the released sessions so callers can publish SSE notifications.
|
||||
func (r *EditSessionRepository) ReleaseForWorkstation(ctx context.Context, workstationID string) ([]EditSession, error) {
|
||||
rows, err := r.db.pool.Query(ctx, `
|
||||
DELETE FROM edit_sessions
|
||||
WHERE workstation_id = $1
|
||||
RETURNING id, item_id, user_id, workstation_id, context_level, object_id, dependency_cone, acquired_at, last_heartbeat
|
||||
`, workstationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sessions []EditSession
|
||||
for rows.Next() {
|
||||
var s EditSession
|
||||
if err := rows.Scan(&s.ID, &s.ItemID, &s.UserID, &s.WorkstationID,
|
||||
&s.ContextLevel, &s.ObjectID, &s.DependencyCone,
|
||||
&s.AcquiredAt, &s.LastHeartbeat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions = append(sessions, s)
|
||||
}
|
||||
return sessions, rows.Err()
|
||||
}
|
||||
|
||||
// GetByID returns an edit session by its ID.
|
||||
func (r *EditSessionRepository) GetByID(ctx context.Context, id string) (*EditSession, error) {
|
||||
s := &EditSession{}
|
||||
err := r.db.pool.QueryRow(ctx, `
|
||||
SELECT id, item_id, user_id, workstation_id, context_level, object_id,
|
||||
dependency_cone, acquired_at, last_heartbeat
|
||||
FROM edit_sessions
|
||||
WHERE id = $1
|
||||
`, id).Scan(&s.ID, &s.ItemID, &s.UserID, &s.WorkstationID,
|
||||
&s.ContextLevel, &s.ObjectID, &s.DependencyCone,
|
||||
&s.AcquiredAt, &s.LastHeartbeat)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ListForItem returns all active edit sessions for an item.
|
||||
func (r *EditSessionRepository) ListForItem(ctx context.Context, itemID string) ([]*EditSession, error) {
|
||||
rows, err := r.db.pool.Query(ctx, `
|
||||
SELECT id, item_id, user_id, workstation_id, context_level, object_id,
|
||||
dependency_cone, acquired_at, last_heartbeat
|
||||
FROM edit_sessions
|
||||
WHERE item_id = $1
|
||||
ORDER BY acquired_at
|
||||
`, itemID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sessions []*EditSession
|
||||
for rows.Next() {
|
||||
s := &EditSession{}
|
||||
if err := rows.Scan(&s.ID, &s.ItemID, &s.UserID, &s.WorkstationID,
|
||||
&s.ContextLevel, &s.ObjectID, &s.DependencyCone,
|
||||
&s.AcquiredAt, &s.LastHeartbeat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions = append(sessions, s)
|
||||
}
|
||||
return sessions, rows.Err()
|
||||
}
|
||||
|
||||
// ListForUser returns all active edit sessions for a user.
|
||||
func (r *EditSessionRepository) ListForUser(ctx context.Context, userID string) ([]*EditSession, error) {
|
||||
rows, err := r.db.pool.Query(ctx, `
|
||||
SELECT id, item_id, user_id, workstation_id, context_level, object_id,
|
||||
dependency_cone, acquired_at, last_heartbeat
|
||||
FROM edit_sessions
|
||||
WHERE user_id = $1
|
||||
ORDER BY acquired_at
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sessions []*EditSession
|
||||
for rows.Next() {
|
||||
s := &EditSession{}
|
||||
if err := rows.Scan(&s.ID, &s.ItemID, &s.UserID, &s.WorkstationID,
|
||||
&s.ContextLevel, &s.ObjectID, &s.DependencyCone,
|
||||
&s.AcquiredAt, &s.LastHeartbeat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions = append(sessions, s)
|
||||
}
|
||||
return sessions, rows.Err()
|
||||
}
|
||||
|
||||
// TouchHeartbeat updates last_heartbeat for all sessions of a workstation.
|
||||
func (r *EditSessionRepository) TouchHeartbeat(ctx context.Context, workstationID string) error {
|
||||
_, err := r.db.pool.Exec(ctx, `
|
||||
UPDATE edit_sessions SET last_heartbeat = now() WHERE workstation_id = $1
|
||||
`, workstationID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ExpireStale deletes sessions whose last_heartbeat is older than the given
|
||||
// timeout, returning the expired sessions for SSE notification.
|
||||
func (r *EditSessionRepository) ExpireStale(ctx context.Context, timeout time.Duration) ([]EditSession, error) {
|
||||
rows, err := r.db.pool.Query(ctx, `
|
||||
DELETE FROM edit_sessions
|
||||
WHERE last_heartbeat < now() - $1::interval
|
||||
RETURNING id, item_id, user_id, workstation_id, context_level, object_id, dependency_cone, acquired_at, last_heartbeat
|
||||
`, timeout.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sessions []EditSession
|
||||
for rows.Next() {
|
||||
var s EditSession
|
||||
if err := rows.Scan(&s.ID, &s.ItemID, &s.UserID, &s.WorkstationID,
|
||||
&s.ContextLevel, &s.ObjectID, &s.DependencyCone,
|
||||
&s.AcquiredAt, &s.LastHeartbeat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions = append(sessions, s)
|
||||
}
|
||||
return sessions, rows.Err()
|
||||
}
|
||||
|
||||
// GetConflict returns the existing session holding a given (item, context_level, object_id)
|
||||
// slot, for building 409 conflict responses.
|
||||
func (r *EditSessionRepository) GetConflict(ctx context.Context, itemID, contextLevel string, objectID *string) (*EditSession, error) {
|
||||
s := &EditSession{}
|
||||
var query string
|
||||
var args []any
|
||||
|
||||
if objectID != nil {
|
||||
query = `
|
||||
SELECT id, item_id, user_id, workstation_id, context_level, object_id,
|
||||
dependency_cone, acquired_at, last_heartbeat
|
||||
FROM edit_sessions
|
||||
WHERE item_id = $1 AND context_level = $2 AND object_id = $3
|
||||
`
|
||||
args = []any{itemID, contextLevel, *objectID}
|
||||
} else {
|
||||
query = `
|
||||
SELECT id, item_id, user_id, workstation_id, context_level, object_id,
|
||||
dependency_cone, acquired_at, last_heartbeat
|
||||
FROM edit_sessions
|
||||
WHERE item_id = $1 AND context_level = $2 AND object_id IS NULL
|
||||
`
|
||||
args = []any{itemID, contextLevel}
|
||||
}
|
||||
|
||||
err := r.db.pool.QueryRow(ctx, query, args...).Scan(
|
||||
&s.ID, &s.ItemID, &s.UserID, &s.WorkstationID,
|
||||
&s.ContextLevel, &s.ObjectID, &s.DependencyCone,
|
||||
&s.AcquiredAt, &s.LastHeartbeat)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
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
|
||||
Size int64
|
||||
ObjectKey string
|
||||
StorageBackend string // "minio" or "filesystem"
|
||||
StorageBackend string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func NewItemFileRepository(db *DB) *ItemFileRepository {
|
||||
// Create inserts a new item file record.
|
||||
func (r *ItemFileRepository) Create(ctx context.Context, f *ItemFile) error {
|
||||
if f.StorageBackend == "" {
|
||||
f.StorageBackend = "minio"
|
||||
f.StorageBackend = "filesystem"
|
||||
}
|
||||
err := r.db.pool.QueryRow(ctx,
|
||||
`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) {
|
||||
rows, err := r.db.pool.Query(ctx,
|
||||
`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`,
|
||||
itemID,
|
||||
)
|
||||
@@ -74,7 +74,7 @@ func (r *ItemFileRepository) Get(ctx context.Context, id string) (*ItemFile, err
|
||||
f := &ItemFile{}
|
||||
err := r.db.pool.QueryRow(ctx,
|
||||
`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`,
|
||||
id,
|
||||
).Scan(&f.ID, &f.ItemID, &f.Filename, &f.ContentType, &f.Size, &f.ObjectKey, &f.StorageBackend, &f.CreatedAt)
|
||||
|
||||
@@ -26,26 +26,26 @@ type Item struct {
|
||||
UpdatedBy *string
|
||||
SourcingType string // "manufactured" or "purchased"
|
||||
LongDescription *string // extended description
|
||||
ThumbnailKey *string // MinIO key for item thumbnail
|
||||
ThumbnailKey *string // storage key for item thumbnail
|
||||
}
|
||||
|
||||
// Revision represents a revision record.
|
||||
type Revision struct {
|
||||
ID string
|
||||
ItemID string
|
||||
RevisionNumber int
|
||||
Properties map[string]any
|
||||
ID string
|
||||
ItemID string
|
||||
RevisionNumber int
|
||||
Properties map[string]any
|
||||
FileKey *string
|
||||
FileVersion *string
|
||||
FileChecksum *string
|
||||
FileSize *int64
|
||||
FileStorageBackend string // "minio" or "filesystem"
|
||||
FileStorageBackend string
|
||||
ThumbnailKey *string
|
||||
CreatedAt time.Time
|
||||
CreatedBy *string
|
||||
Comment *string
|
||||
Status string // draft, review, released, obsolete
|
||||
Labels []string // arbitrary tags
|
||||
CreatedAt time.Time
|
||||
CreatedBy *string
|
||||
Comment *string
|
||||
Status string // draft, review, released, obsolete
|
||||
Labels []string // arbitrary tags
|
||||
}
|
||||
|
||||
// RevisionStatus constants
|
||||
@@ -308,7 +308,7 @@ func (r *ItemRepository) CreateRevision(ctx context.Context, rev *Revision) erro
|
||||
}
|
||||
|
||||
if rev.FileStorageBackend == "" {
|
||||
rev.FileStorageBackend = "minio"
|
||||
rev.FileStorageBackend = "filesystem"
|
||||
}
|
||||
|
||||
err = r.db.pool.QueryRow(ctx, `
|
||||
@@ -347,7 +347,7 @@ func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Re
|
||||
if hasStatusColumn {
|
||||
rows, err = r.db.pool.Query(ctx, `
|
||||
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,
|
||||
COALESCE(status, 'draft') as status, COALESCE(labels, ARRAY[]::TEXT[]) as labels
|
||||
FROM revisions
|
||||
@@ -386,7 +386,7 @@ func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Re
|
||||
)
|
||||
rev.Status = "draft"
|
||||
rev.Labels = []string{}
|
||||
rev.FileStorageBackend = "minio"
|
||||
rev.FileStorageBackend = "filesystem"
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning revision: %w", err)
|
||||
@@ -420,7 +420,7 @@ func (r *ItemRepository) GetRevision(ctx context.Context, itemID string, revisio
|
||||
if hasStatusColumn {
|
||||
err = r.db.pool.QueryRow(ctx, `
|
||||
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,
|
||||
COALESCE(status, 'draft') as status, COALESCE(labels, ARRAY[]::TEXT[]) as labels
|
||||
FROM revisions
|
||||
@@ -443,7 +443,7 @@ func (r *ItemRepository) GetRevision(ctx context.Context, itemID string, revisio
|
||||
)
|
||||
rev.Status = "draft"
|
||||
rev.Labels = []string{}
|
||||
rev.FileStorageBackend = "minio"
|
||||
rev.FileStorageBackend = "filesystem"
|
||||
}
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
|
||||
@@ -328,6 +328,55 @@ func (r *JobRepository) ListJobs(ctx context.Context, status, itemID string, lim
|
||||
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.
|
||||
// Uses SELECT FOR UPDATE SKIP LOCKED for exactly-once delivery.
|
||||
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)
|
||||
);
|
||||
17
internal/db/migrations/023_edit_sessions.sql
Normal file
17
internal/db/migrations/023_edit_sessions.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- 023_edit_sessions.sql — active editing context tracking
|
||||
|
||||
CREATE TABLE edit_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
workstation_id UUID NOT NULL REFERENCES workstations(id) ON DELETE CASCADE,
|
||||
context_level TEXT NOT NULL CHECK (context_level IN ('sketch', 'partdesign', 'assembly')),
|
||||
object_id TEXT,
|
||||
dependency_cone TEXT[],
|
||||
acquired_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_heartbeat TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_edit_sessions_item ON edit_sessions(item_id);
|
||||
CREATE INDEX idx_edit_sessions_user ON edit_sessions(user_id);
|
||||
CREATE UNIQUE INDEX idx_edit_sessions_active ON edit_sessions(item_id, context_level, object_id);
|
||||
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
|
||||
}
|
||||
@@ -64,6 +64,26 @@ type HistoryEntry struct {
|
||||
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.
|
||||
// Each field is optional — nil/empty means the entry is omitted from the ZIP.
|
||||
type PackInput struct {
|
||||
@@ -71,6 +91,7 @@ type PackInput struct {
|
||||
Metadata *Metadata
|
||||
History []HistoryEntry
|
||||
Dependencies []Dependency
|
||||
Approvals []ApprovalEntry
|
||||
}
|
||||
|
||||
// Extract opens a ZIP archive from data and parses the silo/ directory.
|
||||
|
||||
@@ -83,6 +83,11 @@ func Pack(original []byte, input *PackInput) ([]byte, error) {
|
||||
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 {
|
||||
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, Jobs, cfg.Modules.Jobs)
|
||||
applyToggle(r, DAG, cfg.Modules.DAG)
|
||||
applyToggle(r, Solver, cfg.Modules.Solver)
|
||||
applyToggle(r, Sessions, cfg.Modules.Sessions)
|
||||
|
||||
// Step 3: Apply database overrides (highest precedence).
|
||||
if pool != nil {
|
||||
|
||||
@@ -11,6 +11,9 @@ func boolPtr(v bool) *bool { return &v }
|
||||
func TestLoadState_DefaultsOnly(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
cfg := &config.Config{}
|
||||
// Sessions depends on Auth; when auth is disabled via backward-compat
|
||||
// zero value, sessions must also be explicitly disabled.
|
||||
cfg.Modules.Sessions = &config.ModuleToggle{Enabled: boolPtr(false)}
|
||||
|
||||
if err := LoadState(r, cfg, nil); err != nil {
|
||||
t.Fatalf("LoadState: %v", err)
|
||||
@@ -44,8 +47,9 @@ func TestLoadState_BackwardCompat(t *testing.T) {
|
||||
func TestLoadState_YAMLModulesOverrideCompat(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
cfg := &config.Config{}
|
||||
cfg.Auth.Enabled = true // compat says enabled
|
||||
cfg.Modules.Auth = &config.ModuleToggle{Enabled: boolPtr(false)} // explicit says disabled
|
||||
cfg.Auth.Enabled = true // compat says enabled
|
||||
cfg.Modules.Auth = &config.ModuleToggle{Enabled: boolPtr(false)} // explicit says disabled
|
||||
cfg.Modules.Sessions = &config.ModuleToggle{Enabled: boolPtr(false)} // sessions depends on auth
|
||||
|
||||
if err := LoadState(r, cfg, nil); err != nil {
|
||||
t.Fatalf("LoadState: %v", err)
|
||||
|
||||
@@ -21,6 +21,8 @@ const (
|
||||
FreeCAD = "freecad"
|
||||
Jobs = "jobs"
|
||||
DAG = "dag"
|
||||
Solver = "solver"
|
||||
Sessions = "sessions"
|
||||
)
|
||||
|
||||
// ModuleInfo describes a module's metadata.
|
||||
@@ -50,7 +52,7 @@ type Registry struct {
|
||||
var builtinModules = []ModuleInfo{
|
||||
{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: 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: Projects, Name: "Projects", Description: "Project management and item tagging", 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: Jobs, Name: "Job Queue", Description: "Async compute jobs, runner management", DependsOn: []string{Auth}},
|
||||
{ID: DAG, Name: "Dependency DAG", Description: "Feature DAG sync, validation states, interference detection", DependsOn: []string{Jobs}},
|
||||
{ID: Solver, Name: "Solver", Description: "Assembly constraint solving via server-side runners", DependsOn: []string{Jobs}},
|
||||
{ID: Sessions, Name: "Sessions", Description: "Workstation registration, edit sessions, and presence tracking", DependsOn: []string{Auth}, DefaultEnabled: true},
|
||||
}
|
||||
|
||||
// NewRegistry creates a registry with all builtin modules set to their default state.
|
||||
|
||||
@@ -137,8 +137,8 @@ func TestAll_ReturnsAllModules(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
all := r.All()
|
||||
|
||||
if len(all) != 10 {
|
||||
t.Errorf("expected 10 modules, got %d", len(all))
|
||||
if len(all) != 12 {
|
||||
t.Errorf("expected 12 modules, got %d", len(all))
|
||||
}
|
||||
|
||||
// Should be sorted by ID.
|
||||
|
||||
@@ -3,6 +3,7 @@ package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"time"
|
||||
@@ -19,3 +20,21 @@ type FileStore interface {
|
||||
PresignPut(ctx context.Context, key string, expiry time.Duration) (*url.URL, 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:
|
||||
name: assembly-validate
|
||||
version: 1
|
||||
description: "Validate assembly by rebuilding its dependency subgraph"
|
||||
description: "Validate assembly constraints on commit"
|
||||
|
||||
trigger:
|
||||
type: revision_created
|
||||
@@ -12,15 +12,14 @@ job:
|
||||
type: assembly
|
||||
|
||||
compute:
|
||||
type: validate
|
||||
command: create-validate
|
||||
type: custom
|
||||
command: solver-diagnose
|
||||
args:
|
||||
rebuild_mode: incremental
|
||||
check_interference: true
|
||||
operation: diagnose
|
||||
|
||||
runner:
|
||||
tags: [create]
|
||||
tags: [solver]
|
||||
|
||||
timeout: 900
|
||||
timeout: 120
|
||||
max_retries: 2
|
||||
priority: 50
|
||||
priority: 75
|
||||
|
||||
110
migrations/018_kc_metadata.sql
Normal file
110
migrations/018_kc_metadata.sql
Normal file
@@ -0,0 +1,110 @@
|
||||
-- Migration 018: .kc Server-Side Metadata Tables
|
||||
--
|
||||
-- Adds tables for indexing the silo/ directory contents from .kc files.
|
||||
-- See docs/KC_SERVER.md for the full specification.
|
||||
--
|
||||
-- Tables:
|
||||
-- item_metadata - indexed manifest + metadata fields (Section 3.1)
|
||||
-- item_dependencies - CAD-extracted assembly dependencies (Section 3.2)
|
||||
-- item_approvals - ECO workflow state (Section 3.3)
|
||||
-- approval_signatures - individual approval/rejection records (Section 3.3)
|
||||
-- item_macros - registered macros from silo/macros/ (Section 3.4)
|
||||
|
||||
BEGIN;
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- item_metadata: indexed silo/manifest.json + silo/metadata.json
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE item_metadata (
|
||||
item_id UUID PRIMARY KEY REFERENCES items(id) ON DELETE CASCADE,
|
||||
schema_name TEXT,
|
||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||
lifecycle_state TEXT NOT NULL DEFAULT 'draft',
|
||||
fields JSONB NOT NULL DEFAULT '{}',
|
||||
kc_version TEXT,
|
||||
manifest_uuid UUID,
|
||||
silo_instance TEXT,
|
||||
revision_hash TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_by TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_item_metadata_tags ON item_metadata USING GIN (tags);
|
||||
CREATE INDEX idx_item_metadata_lifecycle ON item_metadata (lifecycle_state);
|
||||
CREATE INDEX idx_item_metadata_fields ON item_metadata USING GIN (fields);
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- item_dependencies: indexed silo/dependencies.json
|
||||
--
|
||||
-- Complements the existing `relationships` table.
|
||||
-- relationships = server-authoritative BOM (web UI / API editable)
|
||||
-- item_dependencies = CAD-authoritative record (extracted from .kc)
|
||||
-- BOM merge reconciles the two (see docs/BOM_MERGE.md).
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE item_dependencies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
parent_item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
child_uuid UUID NOT NULL,
|
||||
child_part_number TEXT,
|
||||
child_revision INTEGER,
|
||||
quantity DECIMAL,
|
||||
label TEXT,
|
||||
relationship TEXT NOT NULL DEFAULT 'component',
|
||||
revision_number INTEGER NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_item_deps_parent ON item_dependencies (parent_item_id);
|
||||
CREATE INDEX idx_item_deps_child ON item_dependencies (child_uuid);
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- item_approvals + approval_signatures: ECO workflow
|
||||
--
|
||||
-- Server-authoritative. The .kc silo/approvals.json is a read cache
|
||||
-- packed on checkout for offline display in Create.
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE item_approvals (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
eco_number TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'draft',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_by TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_item_approvals_item ON item_approvals (item_id);
|
||||
CREATE INDEX idx_item_approvals_state ON item_approvals (state);
|
||||
|
||||
CREATE TABLE approval_signatures (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
approval_id UUID NOT NULL REFERENCES item_approvals(id) ON DELETE CASCADE,
|
||||
username TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
signed_at TIMESTAMPTZ,
|
||||
comment TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_approval_sigs_approval ON approval_signatures (approval_id);
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- item_macros: registered macros from silo/macros/
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE item_macros (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
trigger TEXT NOT NULL DEFAULT 'manual',
|
||||
content TEXT NOT NULL,
|
||||
revision_number INTEGER NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(item_id, filename)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_item_macros_item ON item_macros (item_id);
|
||||
|
||||
COMMIT;
|
||||
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
|
||||
# - /etc/silo/silod.env must exist on target with credentials filled in
|
||||
# - PostgreSQL reachable from target (set SILO_DB_HOST to override)
|
||||
# - MinIO reachable from target (set SILO_MINIO_HOST to override)
|
||||
#
|
||||
# Environment variables:
|
||||
# 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)"
|
||||
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
|
||||
LDAP_ADMIN_PW_DEFAULT="$(generate_secret 16)"
|
||||
prompt_secret LDAP_ADMIN_PASSWORD "LDAP admin password" "$LDAP_ADMIN_PW_DEFAULT"
|
||||
@@ -173,10 +167,6 @@ cat > "${OUTPUT_DIR}/.env" << EOF
|
||||
# PostgreSQL
|
||||
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
|
||||
# MinIO
|
||||
MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
|
||||
MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
|
||||
|
||||
# OpenLDAP
|
||||
LDAP_ADMIN_PASSWORD=${LDAP_ADMIN_PASSWORD}
|
||||
LDAP_USERS=${LDAP_USERS}
|
||||
@@ -235,12 +225,9 @@ database:
|
||||
max_connections: 10
|
||||
|
||||
storage:
|
||||
endpoint: "minio:9000"
|
||||
access_key: "${SILO_MINIO_ACCESS_KEY}"
|
||||
secret_key: "${SILO_MINIO_SECRET_KEY}"
|
||||
bucket: "silo-files"
|
||||
use_ssl: false
|
||||
region: "us-east-1"
|
||||
backend: "filesystem"
|
||||
filesystem:
|
||||
root_dir: "/var/lib/silo/data"
|
||||
|
||||
schemas:
|
||||
directory: "/etc/silo/schemas"
|
||||
@@ -306,8 +293,6 @@ echo " deployments/config.docker.yaml - server configuration"
|
||||
echo ""
|
||||
echo -e "${BOLD}Credentials:${NC}"
|
||||
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 User: ${LDAP_USERS} / ${LDAP_PASSWORDS}"
|
||||
echo " Silo Admin: ${SILO_ADMIN_USERNAME} / ${SILO_ADMIN_PASSWORD} (local fallback)"
|
||||
|
||||
@@ -30,7 +30,6 @@ INSTALL_DIR="/opt/silo"
|
||||
CONFIG_DIR="/etc/silo"
|
||||
GO_VERSION="1.24.0"
|
||||
DB_HOST="${SILO_DB_HOST:-psql.example.internal}"
|
||||
MINIO_HOST="${SILO_MINIO_HOST:-minio.example.internal}"
|
||||
|
||||
log_info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||
log_success() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||
@@ -165,11 +164,6 @@ if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
# Database: silo, User: silo
|
||||
SILO_DB_PASSWORD=
|
||||
|
||||
# MinIO credentials (${MINIO_HOST})
|
||||
# User: silouser
|
||||
SILO_MINIO_ACCESS_KEY=silouser
|
||||
SILO_MINIO_SECRET_KEY=
|
||||
|
||||
# Authentication
|
||||
# Session secret (required when auth is enabled)
|
||||
SILO_SESSION_SECRET=
|
||||
@@ -225,10 +219,7 @@ echo ""
|
||||
echo "2. Verify database connectivity:"
|
||||
echo " psql -h ${DB_HOST} -U silo -d silo -c 'SELECT 1'"
|
||||
echo ""
|
||||
echo "3. Verify MinIO connectivity:"
|
||||
echo " curl -I http://${MINIO_HOST}:9000/minio/health/live"
|
||||
echo ""
|
||||
echo "4. Run the deployment:"
|
||||
echo "3. Run the deployment:"
|
||||
echo " sudo ${INSTALL_DIR}/src/scripts/deploy.sh"
|
||||
echo ""
|
||||
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