58 Commits

Author SHA1 Message Date
Forbes
68c9acea5c feat(sessions): edit session acquire, release, and query endpoints
- Add 023_edit_sessions.sql migration with unique index on (item_id, context_level, object_id) for hard interference
- Add EditSessionRepository with Acquire, Release, ReleaseForWorkstation, GetByID, ListForItem, ListForUser, TouchHeartbeat, ExpireStale, GetConflict
- Add 4 handlers: acquire (POST), release (DELETE), list by item (GET), list by user (GET)
- Acquire auto-computes dependency_cone from DAG forward cone when available
- Hard interference returns 409 with holder info (username, workstation, context_level, object_id, acquired_at)
- Publish edit.session_acquired and edit.session_released via item-scoped SSE
- Add /api/edit-sessions (user scope) and /api/items/{pn}/edit-sessions (item scope) routes

Closes #163
2026-03-01 13:40:18 -06:00
Forbes
a669327042 Merge branch 'feat/sse-per-connection-filtering' into feat/edit-sessions 2026-03-01 13:37:44 -06:00
Forbes
e7da3ee94d feat(sse): per-connection filtering with user and workstation context
- Extend sseClient with userID, workstationID, and item filter set
- Update Subscribe() to accept userID and workstationID params
- Add WatchItem/UnwatchItem/IsWatchingItem methods on sseClient
- Add PublishToItem, PublishToWorkstation, PublishToUser targeted delivery
- Targeted events get IDs but skip history ring buffer (real-time only)
- Update HandleEvents to pass auth user ID and workstation_id query param
- Touch workstation last_seen on SSE connect
- Existing Publish() broadcast unchanged; all current callers unaffected
- Add 5 new tests for targeted delivery and item watch lifecycle

Closes #162
2026-03-01 10:04:01 -06:00
Forbes
a851630d85 feat(sessions): workstation table, registration API, and module scaffold
- Add 022_workstations.sql migration (UUID PK, user_id FK, UNIQUE(user_id, name))
- Add Sessions module (depends on Auth, default enabled) with config toggle
- Add WorkstationRepository with Upsert, GetByID, ListByUser, Touch, Delete
- Add workstation handlers: register (POST upsert), list (GET), delete (DELETE)
- Add /api/workstations routes gated by sessions module
- Wire WorkstationRepository into Server struct
- Update module tests for new Sessions module

Closes #161
2026-03-01 09:56:43 -06:00
e5cae28a8c Merge pull request 'feat(api): solver service Phase 3b — server endpoints and job definitions' (#160) from feat/solver-service into main
Reviewed-on: #160
2026-02-20 18:15:20 +00:00
Forbes
5f144878d6 feat(api): solver service Phase 3b — server endpoints, job definitions, and result cache
Add server-side solver service module with REST API endpoints, database
schema, job definitions, and runner result caching.

New files:
- migrations/021_solver_results.sql: solver_results table with upsert constraint
- internal/db/solver_results.go: SolverResultRepository (Upsert, GetByItem, GetByItemRevision)
- internal/api/solver_handlers.go: solver API handlers and maybeCacheSolverResult hook
- jobdefs/assembly-solve.yaml: manual solve job definition
- jobdefs/assembly-validate.yaml: auto-validate on revision creation
- jobdefs/assembly-kinematic.yaml: manual kinematic simulation job

Modified:
- internal/config/config.go: SolverConfig struct with max_context_size_mb, default_timeout
- internal/modules/modules.go, loader.go: register solver module (depends on jobs)
- internal/db/jobs.go: ListSolverJobs helper with definition_name prefix filter
- internal/api/handlers.go: wire SolverResultRepository into Server
- internal/api/routes.go: /api/solver/* routes + /api/items/{partNumber}/solver/results
- internal/api/runner_handlers.go: async result cache hook on job completion

API endpoints:
- POST   /api/solver/jobs          — submit solver job (editor)
- GET    /api/solver/jobs          — list solver jobs with filters
- GET    /api/solver/jobs/{id}     — get solver job status
- POST   /api/solver/jobs/{id}/cancel — cancel solver job (editor)
- GET    /api/solver/solvers       — registry of available solvers
- GET    /api/items/{pn}/solver/results — cached results for item

Also fixes pre-existing test compilation errors (missing workflows param
in NewServer calls across 6 test files).
2026-02-20 12:08:34 -06:00
ed1ac45e12 Merge pull request 'feat(api): approvals + ECO workflows; refactor(storage): remove MinIO' (#154) from feat/approval-workflows into main
Reviewed-on: #154
2026-02-19 20:57:15 +00:00
Forbes
88d1ab1f97 refactor(storage): remove MinIO backend, filesystem-only storage
Remove the MinIO/S3 storage backend entirely. The filesystem backend is
fully implemented, already used in production, and a migrate-storage tool
exists for any remaining MinIO deployments to migrate beforehand.

Changes:
- Delete MinIO client implementation (internal/storage/storage.go)
- Delete migrate-storage tool (cmd/migrate-storage, scripts/migrate-storage.sh)
- Remove MinIO service, volumes, and env vars from all Docker Compose files
- Simplify StorageConfig: remove Endpoint, AccessKey, SecretKey, Bucket,
  UseSSL, Region fields; add SILO_STORAGE_ROOT_DIR env override
- Change all SQL COALESCE defaults from 'minio' to 'filesystem'
- Add migration 020 to update column defaults to 'filesystem'
- Remove minio-go/v7 dependency (go mod tidy)
- Update all config examples, setup scripts, docs, and tests
2026-02-19 14:36:22 -06:00
Forbes
12ecffdabe feat(api): approvals + ECO workflow API with YAML-configurable workflows
- Add internal/workflow/ package for YAML workflow definitions (Load, LoadAll, Validate)
- Add internal/db/item_approvals.go repository (Create, AddSignature, GetWithSignatures, ListByItemWithSignatures, UpdateState, UpdateSignature)
- Add internal/api/approval_handlers.go with 4 endpoints:
  - GET /{partNumber}/approvals (list approvals with signatures)
  - POST /{partNumber}/approvals (create ECO with workflow + signers)
  - POST /{partNumber}/approvals/{id}/sign (approve or reject)
  - GET /workflows (list available workflow definitions)
- Rule-driven state transitions: any_reject and all_required_approve
- Pack approvals into silo/approvals.json on .kc checkout
- Add WorkflowsConfig to config, load workflows at startup
- Migration 019: add workflow_name column to item_approvals
- Example workflows: engineering-change.yaml, quick-review.yaml
- 7 workflow tests, all passing

Closes #145
2026-02-18 19:38:20 -06:00
e260c175bf Merge pull request 'docs: update documentation for .kc file integration (Phases 1-4)' (#153) from docs/kc-integration into main
Reviewed-on: #153
2026-02-19 01:11:23 +00:00
Forbes
bae06da1a1 docs: update documentation for .kc file integration (Phases 1-4)
- SPECIFICATION.md: add 8 KC endpoints to Section 11.1, new Section 11.3
  documenting .kc format, extraction pipeline, packing, lifecycle state
  machine, and all response shapes. Update endpoint count 78 → 86.
- ROADMAP.md: mark .kc Format Spec as Complete in Tier 0 table
- STATUS.md: add KC features to core systems table, update migration
  list through 018, update endpoint count
- MODULES.md: add metadata, dependencies, and macros endpoints to
  core module listing
2026-02-18 19:10:56 -06:00
161c1c1e62 Merge pull request 'feat(api): macro indexing from .kc files and read-only API' (#152) from feat/kc-macros into main
Reviewed-on: #152
2026-02-19 01:06:00 +00:00
df0fc13193 Merge branch 'main' into feat/kc-macros 2026-02-19 01:05:51 +00:00
Forbes
6e6c9c2c75 feat(api): macro indexing from .kc files and read-only API
- Add MacroFile type to internal/kc and extract silo/macros/* files
  from .kc ZIP archives on commit
- Create ItemMacroRepository with ReplaceForItem, ListByItem, and
  GetByFilename methods
- Add GET /{partNumber}/macros (list) and
  GET /{partNumber}/macros/{filename} (source content) endpoints
- Index macros in extractKCMetadata with SSE broadcast
- List endpoint omits content for lightweight responses

Closes #144
2026-02-18 19:03:44 -06:00
98be1fa78c Merge pull request 'feat(api): item dependency extraction, indexing, and resolve endpoints' (#151) from feat/kc-dependencies into main
Reviewed-on: #151
2026-02-19 00:55:55 +00:00
f8b8eda973 Merge branch 'main' into feat/kc-dependencies 2026-02-19 00:55:40 +00:00
Forbes
cffcf56085 feat(api): item dependency extraction, indexing, and resolve endpoints
- Add Dependency type to internal/kc and extract silo/dependencies.json
  from .kc files on commit
- Create ItemDependencyRepository with ReplaceForRevision, ListByItem,
  and Resolve (LEFT JOIN against items table)
- Add GET /{partNumber}/dependencies and
  GET /{partNumber}/dependencies/resolve endpoints
- Index dependencies in extractKCMetadata with SSE broadcast
- Pack real dependency data into .kc files on checkout
- Update PackInput.Dependencies from []any to []Dependency

Closes #143
2026-02-18 18:53:40 -06:00
1a34455ad5 Merge pull request 'feat(kc): checkout packing + ETag caching (Phase 2)' (#150) from feat/kc-checkout-packing into main
Reviewed-on: #150
2026-02-18 23:06:17 +00:00
Forbes
c216d64702 feat(kc): checkout packing + ETag caching (Phase 2)
Implements issue #142 — .kc checkout pipeline that repacks silo/ entries
with current DB state before serving downloads.

When a client downloads a .kc file via GET /api/items/{pn}/file/{rev},
the server now:
1. Reads the file from storage into memory
2. Checks for silo/ directory (plain .fcstd files bypass packing)
3. Repacks silo/ entries with current item_metadata + revision history
4. Streams the repacked ZIP to the client

New files:
- internal/kc/pack.go: Pack() replaces silo/ entries in ZIP, preserving
  all non-silo entries (FreeCAD files, thumbnails) with original
  compression and timestamps. HasSiloDir() for lightweight detection.
- internal/api/pack_handlers.go: packKCFile server helper, computeETag,
  canSkipRepack lazy optimization.

ETag caching:
- ETag computed from revision_number + metadata.updated_at
- If-None-Match support returns 304 Not Modified before reading storage
- Cache-Control: private, must-revalidate

Lazy packing optimization:
- Skips repack if revision_hash matches and metadata unchanged since upload

Phase 2 packs: manifest.json, metadata.json, history.json,
dependencies.json (empty []). Approvals, macros, jobs deferred to
Phase 3-5.

Closes #142
2026-02-18 17:01:26 -06:00
28f133411e Merge pull request 'feat(kc): commit extraction pipeline + metadata API (Phase 1)' (#149) from feat/kc-extraction-pipeline into main
Reviewed-on: #149
2026-02-18 22:39:59 +00:00
6528df0461 Merge branch 'main' into feat/kc-extraction-pipeline 2026-02-18 22:39:49 +00:00
Forbes
dd010331c0 feat(kc): commit extraction pipeline + metadata API (Phase 1)
Implements issue #141 — .kc server-side metadata integration Phase 1.

When a .kc file is uploaded, the server extracts silo/manifest.json and
silo/metadata.json from the ZIP archive and indexes them into the
item_metadata table. Plain .fcstd files continue to work unchanged.
Extraction is best-effort: failures are logged but do not block the upload.

New packages:
- internal/kc: ZIP extraction library (Extract, Manifest, Metadata types)
- internal/db: ItemMetadataRepository (Get, Upsert, UpdateFields,
  UpdateLifecycle, SetTags)

New API endpoints under /api/items/{partNumber}:
- GET    /metadata           — read indexed metadata (viewer)
- PUT    /metadata           — merge fields into JSONB (editor)
- PATCH  /metadata/lifecycle — transition lifecycle state (editor)
- PATCH  /metadata/tags      — add/remove tags (editor)

SSE events: metadata.updated, metadata.lifecycle, metadata.tags

Lifecycle transitions (Phase 1): draft→review→released→obsolete,
review→draft (reject).

Closes #141
2026-02-18 16:37:39 -06:00
628cd1d252 Merge pull request 'feat(db): .kc metadata database migration' (#148) from feat/kc-metadata-migration into main
Reviewed-on: #148
2026-02-18 21:05:15 +00:00
Forbes
8d777e83bb feat(db): .kc metadata database migration (#140)
Add migration 018_kc_metadata.sql with all tables needed for .kc
server-side metadata indexing:

- item_metadata: indexed manifest + metadata fields from silo/
  directory (tags, lifecycle_state, fields JSONB, manifest info)
- item_dependencies: CAD-extracted assembly dependencies
  (complements existing relationships table)
- item_approvals + approval_signatures: ECO workflow state
- item_macros: registered macros from silo/macros/

Also adds docs/KC_SERVER.md specification document.

Closes #140
2026-02-18 15:04:03 -06:00
d96ba8d394 Merge pull request 'docs: replace MinIO with filesystem storage throughout' (#139) from update-silo-fs-docs into main
Reviewed-on: #139
2026-02-18 20:46:51 +00:00
Forbes
56c76940ed docs: replace MinIO with filesystem storage throughout
Remove all MinIO/S3 references from documentation and deployment
configs. Silo now uses local filesystem storage exclusively.

Updated files:
- docs/CONFIGURATION.md: storage section now documents filesystem backend
- docs/DEPLOYMENT.md: architecture diagram, external services, troubleshooting
- docs/INSTALL.md: remove MinIO setup, update architecture diagrams
- docs/SPECIFICATION.md: architecture, technology stack, file storage strategy
- docs/STATUS.md: storage backend status
- docs/GAP_ANALYSIS.md: file handling references
- docs/ROADMAP.md: file storage appendix entries
- deployments/config.prod.yaml: filesystem backend config
- deployments/systemd/silod.env.example: remove MinIO credential vars
2026-02-18 14:45:00 -06:00
9dabaf5796 Merge pull request 'feat(scripts): remote migrate-storage script for MinIO to filesystem migration' (#138) from feat-remote-migrate-storage into main
Reviewed-on: #138
2026-02-18 20:33:38 +00:00
Forbes
3bb335397c feat(scripts): remote migrate-storage script for MinIO to filesystem migration
Adds scripts/migrate-storage.sh that follows the same deploy.sh pattern:
cross-compiles the migrate-storage binary locally, uploads it to the
target host via SCP, then runs it over SSH using credentials from
/etc/silo/silod.env.

Usage: ./scripts/migrate-storage.sh <silo-host> <psql-host> <minio-host> [flags...]
2026-02-18 14:29:46 -06:00
344a0cd0a0 Merge pull request 'feat(storage): add MinIO to filesystem migration tool' (#137) from feat/migrate-storage-tool into main
Reviewed-on: #137
2026-02-18 20:16:17 +00:00
forbes
f5b03989ff feat(storage): add MinIO to filesystem migration tool
Standalone binary (cmd/migrate-storage) that downloads all files from
MinIO and writes them to the local filesystem for decommissioning MinIO.

Queries revision files, item file attachments, and item thumbnails from
the database, then downloads each from MinIO preserving the object key
structure as filesystem paths. Supports --dry-run, --verbose, atomic
writes via temp+rename, and idempotent re-runs (skips existing files
with matching size).
2026-02-18 14:12:32 -06:00
8cd92a4025 Merge pull request 'feat(api): direct multipart upload endpoints for filesystem backend' (#136) from feat-direct-upload into main
Reviewed-on: #136
2026-02-17 19:05:39 +00:00
ffa01ebeb7 feat(api): direct multipart upload endpoints for filesystem backend
Add three new endpoints that bypass the MinIO presigned URL flow:
- POST /api/items/{pn}/files/upload — multipart file upload
- POST /api/items/{pn}/thumbnail/upload — multipart thumbnail upload
- GET /api/items/{pn}/files/{fileId}/download — stream file download

Rewrite frontend upload flow: files are held in browser memory on drop
and uploaded directly after item creation via multipart POST. The old
presign+associate endpoints remain for MinIO backward compatibility.

Closes #129
2026-02-17 13:04:44 -06:00
9181673554 Merge pull request 'feat(db): add storage backend metadata columns' (#135) from feat-file-storage-metadata into main
Reviewed-on: #135
2026-02-17 18:32:05 +00:00
8cef4fa55f feat(db): add storage backend metadata columns
Add storage_backend columns to track which backend (minio or filesystem)
holds each file, enabling dual-running during migration.

Migration 017_file_storage_metadata.sql:
- item_files.storage_backend TEXT NOT NULL DEFAULT 'minio'
- revisions.file_storage_backend TEXT NOT NULL DEFAULT 'minio'

DB repository changes:
- Revision struct: add FileStorageBackend field
- ItemFile struct: add StorageBackend field
- All INSERT queries include the new columns
- All SELECT queries read them (COALESCE for pre-migration compat)
- CreateRevisionFromExisting copies the backend from source revision
- Default to 'minio' when field is empty (backward compat)

Existing rows default to 'minio'. New uploads will write 'filesystem'
when the filesystem backend is active.

Closes #128
2026-02-17 12:30:20 -06:00
7a9dd057a5 Merge pull request 'feat(storage): FileStore interface abstraction + filesystem backend' (#134) from feat-storage-interface-filesystem into main
Reviewed-on: #134
2026-02-17 17:55:09 +00:00
9f347e7898 feat(storage): implement filesystem backend
Implement FilesystemStore satisfying the FileStore interface for local
filesystem storage, replacing MinIO for simpler deployments.

- Atomic writes via temp file + os.Rename (no partial files)
- SHA-256 checksum computed on Put via io.MultiWriter
- Get/GetVersion return os.File (GetVersion ignores versionID)
- Delete is idempotent (no error if file missing)
- Copy uses same atomic write pattern
- PresignPut returns ErrPresignNotSupported
- Ping verifies root directory is writable
- Wire NewFilesystemStore in main.go backend switch
- 14 unit tests covering all methods including atomicity

Closes #127
2026-02-17 11:49:42 -06:00
b531617e39 feat(storage): define FileStore interface and refactor to use it
Extract a FileStore interface from the concrete *storage.Storage MinIO
wrapper so the API layer is storage-backend agnostic.

- Define FileStore interface in internal/storage/interface.go
- Add Exists method to MinIO Storage (via StatObject)
- Add compile-time interface satisfaction check
- Change Server.storage and ServerState.storage to FileStore interface
- Update NewServer and NewServerState signatures
- Add Backend and FilesystemConfig fields to StorageConfig
- Add backend selection switch in main.go (minio/filesystem/unknown)
- Update config.example.yaml with backend field

The nil-interface pattern is preserved: when storage is unconfigured,
store remains a true nil FileStore (not a typed nil pointer), so all
existing if s.storage == nil checks continue to work correctly.

Closes #126
2026-02-17 11:49:35 -06:00
906277149e Merge pull request 'feat(web): read-write configuration from admin UI' (#124) from feat-admin-config-ui into main
Reviewed-on: #124
2026-02-15 23:12:04 +00:00
Forbes
fc4826f576 feat(web): read-write configuration from admin UI
Convert all module settings from read-only to editable fields in the
admin settings page:

- Core: host, port, base_url (read-only stays read-only)
- Schemas: directory, default (count stays read-only)
- Database: host, port, name, user, password, sslmode (dropdown),
  max_connections
- Storage: endpoint, bucket, use_ssl (checkbox), region
- Auth: local/ldap/oidc sub-sections with enabled checkboxes,
  connection fields, and secret fields (password input for redacted)

New field components: SelectField (dropdown), CheckboxField (toggle).
Redacted fields now render as password inputs with placeholder.
Auth uses nested key handling to send sub-section objects.

Backend already persists overrides and flags restart-required changes.

Closes #117
2026-02-15 13:33:48 -06:00
fbfc955ccc Merge pull request 'feat(modules): SSE settings.changed event broadcast' (#123) from feat-sse-settings-changed into main
Reviewed-on: #123
2026-02-15 19:14:36 +00:00
e0295e7180 Merge branch 'main' into feat-sse-settings-changed 2026-02-15 19:14:26 +00:00
Forbes
7fec219152 feat(modules): SSE settings.changed event broadcast and UI reactions
Add useSSE hook that connects to /api/events with automatic reconnect
and exponential backoff. On settings.changed events:

- Refresh module state so sidebar nav items show/hide immediately
- Show dismissable toast when another admin updates settings

The backend already publishes settings.changed in HandleUpdateModuleSettings.

Closes #101
2026-02-15 13:11:04 -06:00
fa069eb05c Merge pull request 'feat(web): move edit/delete buttons into tab bar on item detail' (#122) from feat-move-edit-delete-buttons into main
Reviewed-on: #122
2026-02-15 19:03:59 +00:00
Forbes
8735c8341b feat(web): move edit/delete buttons into tab bar on item detail
Relocate Edit and Delete buttons from the header row into the tab bar,
grouping them with tab navigation to reduce mouse travel. Adds Pencil
and Trash2 icons for quick visual recognition.

Header now only shows part number, type badge, and close button.

Closes #119
2026-02-15 12:59:40 -06:00
7a172ce34c Merge pull request 'feat(web): favicon, narrow settings, scrollable token list' (#121) from feat-ui-tweaks into main
Reviewed-on: #121
2026-02-15 18:47:03 +00:00
Forbes
da65d4bc1a feat(web): favicon, narrow settings, scrollable token list
- Add kindred-logo.svg as site favicon (#115)
- Narrow settings page to 66% max-width, centered (#116)
- Add max-height and scroll to API token table (#118)

Closes #115, closes #116, closes #118
2026-02-15 12:38:20 -06:00
57d5a786d0 Merge pull request 'feat(web): collapsible left sidebar, remove top nav bar' (#120) from feat-sidebar-nav into main
Reviewed-on: #120
2026-02-15 18:33:09 +00:00
Forbes
42a901f39c feat(web): collapsible left sidebar, remove top nav bar
- Replace top header with left sidebar navigation
- Sidebar shows module-aware nav items filtered by /api/modules
- Collapsible: expanded shows icon+label, collapsed shows icon only
- Toggle with Ctrl+J or collapse button, state persisted in localStorage
- Keyboard navigable: Arrow Up/Down, Enter to navigate, Escape to collapse
- Bottom section: density toggle, user info with role badge, logout
- Add useModules hook for fetching module state
- Add sidebar density variables to theme.css

Closes #113, closes #114
2026-02-15 12:32:52 -06:00
666cc2b23b Merge pull request 'feat(jobs): wire auto-triggering on bom_changed events' (#112) from feat-job-auto-trigger into main
Reviewed-on: #112
2026-02-15 15:44:42 +00:00
Forbes
747bae8354 feat(jobs): wire auto-triggering on bom_changed events, add module guard
- Add IsEnabled("jobs") guard to triggerJobs() to skip when module disabled
- Fire bom_changed trigger from HandleAddBOMEntry, HandleUpdateBOMEntry,
  HandleDeleteBOMEntry (matching existing HandleMergeBOM pattern)
- Add 4 integration tests: revision trigger, BOM trigger, filter mismatch,
  module disabled
- Fix AppShell overflow: hidden -> auto so Settings page scrolls
- Clean old frontend assets in deploy script before extracting

Closes #107
2026-02-15 09:43:05 -06:00
71603bb6d7 Merge pull request 'feat: location hierarchy CRUD API' (#106) from feat-location-crud into main
Reviewed-on: #106
2026-02-15 09:16:52 +00:00
Forbes
4ef912cf4b feat: location hierarchy CRUD API
Add LocationRepository with CRUD operations, hierarchy traversal
(children, subtree by path prefix), and inventory-safe deletion.

Endpoints:
  GET    /api/locations          — list all or ?tree={path} for subtree
  POST   /api/locations          — create (auto-resolves parent_id, depth)
  GET    /api/locations/{path..} — get by hierarchical path
  PUT    /api/locations/{path..} — update name, type, metadata
  DELETE /api/locations/{path..} — delete (rejects if inventory exists)

Uses chi wildcard routes to support multi-segment paths like
/api/locations/lab/shelf-a/bin-3.

Includes 10 handler integration tests covering CRUD, nesting,
validation, duplicates, tree queries, and delete-not-found.

Closes #81
2026-02-15 03:15:54 -06:00
decb32c3e7 Merge pull request 'feat(web): admin settings page — module cards, toggles, config forms' (#105) from feat-admin-settings-api into main
Reviewed-on: #105
2026-02-15 09:09:17 +00:00
Forbes
0be39065ac feat(web): admin settings page with module cards, toggles, config forms
Add admin-only Module Configuration section to the Settings page.
Each module gets a collapsible card with enable/disable toggle,
status badge, module-specific config fields, save and test
connectivity buttons.

- AdminModules: fetches GET /api/modules + GET /api/admin/settings,
  renders Infrastructure and Features groups, restart banner
- ModuleCard: collapsible card with toggle, status badge, field
  layouts per module, save (PUT) and test (POST) actions
- TypeScript types for ModuleInfo, ModulesResponse, admin settings
  API response shapes

Ref: #100
2026-02-15 03:01:33 -06:00
Forbes
101d04ab6f test(api): admin settings handler tests
- TestGetAllSettings — all module keys present, secrets redacted
- TestGetModuleSettings — single module response
- TestGetModuleSettings_Unknown — 404 for unknown module
- TestToggleModule — disable projects, verify registry state
- TestToggleModule_DependencyError — enable dag without jobs, expect 400
- TestToggleRequiredModule — disable core, expect 400
- TestTestConnectivity_Database — ping database, expect success
- TestTestConnectivity_NotTestable — core module, expect 400
2026-02-15 02:51:00 -06:00
Forbes
8167d9c216 feat(api): admin settings API endpoints
Add four admin-only endpoints under /api/admin/settings:

- GET  /                — full config (secrets redacted)
- GET  /{module}        — single module config
- PUT  /{module}        — toggle modules + persist config overrides
- POST /{module}/test   — test external connectivity (database, storage)

PUT publishes a settings.changed SSE event. Config overrides are
persisted for future hot-reload support; changes to database/storage/
server/schemas namespaces return restart_required: true.

Wires SettingsRepository into Server struct.

Closes #99
2026-02-15 02:51:00 -06:00
Forbes
319a739adb feat(db): add SettingsRepository for module state and config overrides
Provides CRUD operations on the module_state and settings_overrides
tables (created in migration 016).

- GetModuleStates / SetModuleState — upsert module enabled/disabled
- GetOverrides / SetOverride / DeleteOverride — JSONB config overrides

Part of #99
2026-02-15 02:51:00 -06:00
e20252a993 Merge pull request 'feat: module system — registry, middleware, and discovery endpoint' (#102) from feat-module-system into main
Reviewed-on: #102
2026-02-14 20:05:42 +00:00
109 changed files with 10323 additions and 901 deletions

View File

@@ -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

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# Binaries
/silo
/silod
/migrate-storage
*.exe
*.dll
*.so

View File

@@ -68,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
@@ -95,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
@@ -167,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"

View File

@@ -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
```

View File

@@ -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,23 +64,15 @@ func main() {
defer database.Close()
logger.Info().Msg("connected to database")
// Connect to storage (optional - may be externally managed)
var store *storage.Storage
if cfg.Storage.Endpoint != "" {
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.Warn().Err(err).Msg("failed to connect to storage - file operations disabled")
store = nil
} else {
logger.Info().Msg("connected to storage")
// Connect to storage (optional — requires root_dir to be set)
var store storage.FileStore
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")
} else {
logger.Info().Msg("storage not configured - file operations disabled")
}
@@ -220,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 {
@@ -243,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)

View File

@@ -17,12 +17,9 @@ database:
max_connections: 10
storage:
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"
backend: "filesystem"
filesystem:
root_dir: "/opt/silo/data" # Override with SILO_STORAGE_ROOT_DIR env var
schemas:
# Directory containing YAML schema files

View File

@@ -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"

View File

@@ -10,8 +10,6 @@
#
# Credentials via environment variables (set in /etc/silo/silod.env):
# SILO_DB_PASSWORD
# SILO_MINIO_ACCESS_KEY
# SILO_MINIO_SECRET_KEY
# SILO_SESSION_SECRET
# SILO_ADMIN_PASSWORD
@@ -30,12 +28,9 @@ database:
max_connections: 20
storage:
endpoint: "minio.example.internal:9000"
access_key: "" # Set via SILO_MINIO_ACCESS_KEY
secret_key: "" # Set via SILO_MINIO_SECRET_KEY
bucket: "silo-files"
use_ssl: true
region: "us-east-1"
backend: "filesystem"
filesystem:
root_dir: "/opt/silo/data"
schemas:
directory: "/opt/silo/schemas"

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -6,10 +6,6 @@
# Database: silo, User: silo
SILO_DB_PASSWORD=
# MinIO credentials (minio.example.internal)
# User: silouser
SILO_MINIO_ACCESS_KEY=silouser
SILO_MINIO_SECRET_KEY=
# Authentication
# Session secret (required when auth is enabled)

View File

@@ -27,6 +27,7 @@ NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/opt/silo/data
ReadOnlyPaths=/etc/silo /opt/silo
# Resource limits

View File

@@ -73,25 +73,27 @@ database:
---
## Storage (MinIO/S3)
## Storage (Filesystem)
| Key | Type | Default | Env Override | Description |
|-----|------|---------|-------------|-------------|
| `storage.endpoint` | string | — | `SILO_MINIO_ENDPOINT` | MinIO/S3 endpoint (`host:port`) |
| `storage.access_key` | string | — | `SILO_MINIO_ACCESS_KEY` | Access key |
| `storage.secret_key` | string | — | `SILO_MINIO_SECRET_KEY` | Secret key |
| `storage.bucket` | string | — | — | S3 bucket name (created automatically if missing) |
| `storage.use_ssl` | bool | `false` | — | Use HTTPS for MinIO connections |
| `storage.region` | string | `"us-east-1"` | — | S3 region |
Files are stored on the local filesystem under a configurable root directory.
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `storage.backend` | string | `"filesystem"` | Storage backend (`filesystem`) |
| `storage.filesystem.root_dir` | string | — | Root directory for file storage (required) |
```yaml
storage:
endpoint: "localhost:9000"
access_key: "" # use SILO_MINIO_ACCESS_KEY env var
secret_key: "" # use SILO_MINIO_SECRET_KEY env var
bucket: "silo-files"
use_ssl: false
region: "us-east-1"
backend: "filesystem"
filesystem:
root_dir: "/opt/silo/data"
```
Ensure the directory exists and is writable by the `silo` user:
```bash
sudo mkdir -p /opt/silo/data
sudo chown silo:silo /opt/silo/data
```
---
@@ -264,9 +266,6 @@ All environment variable overrides. These take precedence over values in `config
| `SILO_DB_NAME` | `database.name` | PostgreSQL database name |
| `SILO_DB_USER` | `database.user` | PostgreSQL user |
| `SILO_DB_PASSWORD` | `database.password` | PostgreSQL password |
| `SILO_MINIO_ENDPOINT` | `storage.endpoint` | MinIO endpoint |
| `SILO_MINIO_ACCESS_KEY` | `storage.access_key` | MinIO access key |
| `SILO_MINIO_SECRET_KEY` | `storage.secret_key` | MinIO secret key |
| `SILO_SESSION_SECRET` | `auth.session_secret` | Session cookie signing secret |
| `SILO_ADMIN_USERNAME` | `auth.local.default_admin_username` | Default admin username |
| `SILO_ADMIN_PASSWORD` | `auth.local.default_admin_password` | Default admin password |
@@ -296,11 +295,9 @@ database:
sslmode: "disable"
storage:
endpoint: "localhost:9000"
access_key: "minioadmin"
secret_key: "minioadmin"
bucket: "silo-files"
use_ssl: false
backend: "filesystem"
filesystem:
root_dir: "./data"
schemas:
directory: "./schemas"

View File

@@ -4,7 +4,7 @@
> instructions. This document covers ongoing maintenance and operations for an
> existing deployment.
This guide covers deploying Silo to a dedicated VM using external PostgreSQL and MinIO services.
This guide covers deploying Silo to a dedicated VM using external PostgreSQL and local filesystem storage.
## Table of Contents
@@ -26,28 +26,25 @@ This guide covers deploying Silo to a dedicated VM using external PostgreSQL and
│ │ silod │ │
│ │ (Silo API Server) │ │
│ │ :8080 │ │
│ │ Files: /opt/silo/data │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────┐ ┌─────────────────────────────────┐
│ psql.example.internal │ │ minio.example.internal │
│ PostgreSQL 16 │ │ MinIO S3 │
│ :5432 │ │ :9000 (API) │
│ │ │ :9001 (Console) │
└─────────────────────────┘ └─────────────────────────────────┘
┌─────────────────────────┐
│ psql.example.internal │
│ PostgreSQL 16 │
│ :5432 │
└─────────────────────────┘
```
## External Services
The following external services are already configured:
| Service | Host | Database/Bucket | User |
|---------|------|-----------------|------|
| Service | Host | Database | User |
|---------|------|----------|------|
| PostgreSQL | psql.example.internal:5432 | silo | silo |
| MinIO | minio.example.internal:9000 | silo-files | silouser |
Migrations have been applied to the database.
Files are stored on the local filesystem at `/opt/silo/data`. Migrations have been applied to the database.
---
@@ -107,21 +104,15 @@ Fill in the values:
# Database credentials (psql.example.internal)
SILO_DB_PASSWORD=your-database-password
# MinIO credentials (minio.example.internal)
SILO_MINIO_ACCESS_KEY=silouser
SILO_MINIO_SECRET_KEY=your-minio-secret-key
```
### Verify External Services
Before deploying, verify connectivity to external services:
Before deploying, verify connectivity to PostgreSQL:
```bash
# Test PostgreSQL
psql -h psql.example.internal -U silo -d silo -c 'SELECT 1'
# Test MinIO
curl -I http://minio.example.internal:9000/minio/health/live
```
---
@@ -183,6 +174,7 @@ sudo -E /opt/silo/src/scripts/deploy.sh
| File | Purpose |
|------|---------|
| `/opt/silo/bin/silod` | Server binary |
| `/opt/silo/data/` | File storage root |
| `/opt/silo/src/` | Git repository checkout |
| `/etc/silo/config.yaml` | Server configuration |
| `/etc/silo/silod.env` | Environment variables (secrets) |
@@ -242,7 +234,7 @@ sudo journalctl -u silod --since "2024-01-15 10:00:00"
# Basic health check
curl http://localhost:8080/health
# Full readiness check (includes DB and MinIO)
# Full readiness check (includes DB)
curl http://localhost:8080/ready
```
@@ -318,24 +310,6 @@ psql -h psql.example.internal -U silo -d silo -f /opt/silo/src/migrations/008_ne
3. Check `pg_hba.conf` on PostgreSQL server allows connections from this host.
### Connection Refused to MinIO
1. Test network connectivity:
```bash
nc -zv minio.example.internal 9000
```
2. Test with curl:
```bash
curl -I http://minio.example.internal:9000/minio/health/live
```
3. Check SSL settings in config match MinIO setup:
```yaml
storage:
use_ssl: true # or false
```
### Health Check Fails
```bash
@@ -345,7 +319,9 @@ curl -v http://localhost:8080/ready
# If ready fails but health passes, check external services
psql -h psql.example.internal -U silo -d silo -c 'SELECT 1'
curl http://minio.example.internal:9000/minio/health/live
# Check file storage directory
ls -la /opt/silo/data
```
### Build Fails
@@ -460,10 +436,9 @@ sudo systemctl reload nginx
- [ ] `/etc/silo/silod.env` has mode 600 (`chmod 600`)
- [ ] Database password is strong and unique
- [ ] MinIO credentials are specific to silo (not admin)
- [ ] SSL/TLS enabled for PostgreSQL (`sslmode: require`)
- [ ] SSL/TLS enabled for MinIO (`use_ssl: true`) if available
- [ ] HTTPS enabled via nginx reverse proxy
- [ ] File storage directory (`/opt/silo/data`) owned by `silo` user with mode 750
- [ ] Silod listens on localhost only (`host: 127.0.0.1`)
- [ ] Firewall allows only ports 80, 443 (not 8080)
- [ ] Service runs as non-root `silo` user

View File

@@ -76,7 +76,7 @@ See [ROADMAP.md](ROADMAP.md) for the platform roadmap and dependency tier struct
| Append-only revision history | Complete | `internal/db/items.go` |
| Sequential revision numbering | Complete | Database trigger |
| Property snapshots (JSONB) | Complete | `revisions.properties` |
| File versioning (MinIO) | Complete | `internal/storage/` |
| File storage (filesystem) | Complete | `internal/storage/` |
| SHA256 checksums | Complete | Captured on upload |
| Revision comments | Complete | `revisions.comment` |
| User attribution | Complete | `revisions.created_by` |
@@ -93,7 +93,7 @@ CREATE TABLE revisions (
revision_number INTEGER NOT NULL,
properties JSONB NOT NULL DEFAULT '{}',
file_key TEXT,
file_version TEXT, -- MinIO version ID
file_version TEXT, -- storage version ID
file_checksum TEXT, -- SHA256
file_size BIGINT,
thumbnail_key TEXT,
@@ -283,7 +283,7 @@ Effort: Medium | Priority: Low | Risk: Low
**Changes:**
- Add thumbnail generation on file upload
- Store in MinIO at `thumbnails/{part_number}/rev{n}.png`
- Store at `thumbnails/{part_number}/rev{n}.png`
- Expose via `GET /api/items/{pn}/thumbnail/{rev}`
---
@@ -377,7 +377,7 @@ internal/
relationships.go # BOM repository
projects.go # Project repository
storage/
storage.go # MinIO file storage helpers
storage.go # File storage helpers
migrations/
001_initial.sql # Core schema
...
@@ -572,7 +572,7 @@ Reporting capabilities are absent. Basic reports (item counts, revision activity
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|---------|---------------|-------------|----------|------------|
| File versioning | Automatic | Full (MinIO) | - | - |
| File versioning | Automatic | Full (filesystem) | - | - |
| File preview | Thumbnails, 3D preview | None | Medium | Complex |
| File conversion | PDF, DXF generation | None | Medium | Complex |
| Replication | Multi-site sync | None | Low | Complex |

View File

@@ -3,7 +3,7 @@
This guide covers two installation methods:
- **[Option A: Docker Compose](#option-a-docker-compose)** — self-contained stack with all services. Recommended for evaluation, small teams, and environments where Docker is the standard.
- **[Option B: Daemon Install](#option-b-daemon-install-systemd--external-services)** — systemd service with external PostgreSQL, MinIO, and optional LDAP/nginx. Recommended for production deployments integrated with existing infrastructure.
- **[Option B: Daemon Install](#option-b-daemon-install-systemd--external-services)** — systemd service with external PostgreSQL and optional LDAP/nginx. Files are stored on the local filesystem. Recommended for production deployments integrated with existing infrastructure.
Both methods produce the same result: a running Silo server with a web UI, REST API, and authentication.
@@ -48,7 +48,7 @@ Regardless of which method you choose:
## Option A: Docker Compose
A single Docker Compose file runs everything: PostgreSQL, MinIO, OpenLDAP, and Silo. An optional nginx container can be enabled for reverse proxying.
A single Docker Compose file runs everything: PostgreSQL, OpenLDAP, and Silo. Files are stored on the local filesystem. An optional nginx container can be enabled for reverse proxying.
### A.1 Prerequisites
@@ -80,7 +80,6 @@ The setup script generates credentials and configuration files:
It prompts for:
- Server domain (default: `localhost`)
- PostgreSQL password (auto-generated if you press Enter)
- MinIO credentials (auto-generated)
- OpenLDAP admin password and initial user (auto-generated)
- Silo local admin account (fallback when LDAP is unavailable)
@@ -106,7 +105,7 @@ Wait for all services to become healthy:
docker compose -f deployments/docker-compose.allinone.yaml ps
```
You should see `silo-postgres`, `silo-minio`, `silo-openldap`, and `silo-api` all in a healthy state.
You should see `silo-postgres`, `silo-openldap`, and `silo-api` all in a healthy state.
View logs:
@@ -124,7 +123,7 @@ docker compose -f deployments/docker-compose.allinone.yaml logs -f silo
# Health check
curl http://localhost:8080/health
# Readiness check (includes database and storage connectivity)
# Readiness check (includes database connectivity)
curl http://localhost:8080/ready
```
@@ -226,7 +225,7 @@ The Silo container is rebuilt from the updated source. Database migrations in `m
## Option B: Daemon Install (systemd + External Services)
This method runs Silo as a systemd service on a dedicated host, connecting to externally managed PostgreSQL, MinIO, and optionally LDAP services.
This method runs Silo as a systemd service on a dedicated host, connecting to externally managed PostgreSQL and optionally LDAP services. Files are stored on the local filesystem.
### B.1 Architecture Overview
@@ -240,21 +239,22 @@ This method runs Silo as a systemd service on a dedicated host, connecting to ex
│ ┌───────▼────────┐ │
│ │ silod │ │
│ │ (API server) │ │
└──┬─────────┬───┘
└─────┼─────────┼──────┘
┌─────────────┐ ┌─────────────────
│ PostgreSQL 16│ │ MinIO (S3)
:5432 │ │ :9000 API │
└──────────────┘ │ :9001 Console
└──────────────────┘
│ Files: /opt/ │
│ │ silo/data │ │
│ └──────┬─────────┘
─────────────────────
┌───────────▼──┐
│ PostgreSQL 16
│ :5432 │
└──────────────┘
```
### B.2 Prerequisites
- Linux host (Debian/Ubuntu or RHEL/Fedora/AlmaLinux)
- Root or sudo access
- Network access to your PostgreSQL and MinIO servers
- Network access to your PostgreSQL server
The setup script installs Go and other build dependencies automatically.
@@ -281,26 +281,6 @@ Verify:
psql -h YOUR_PG_HOST -U silo -d silo -c 'SELECT 1'
```
#### MinIO
Install MinIO and create a bucket and service account:
- [MinIO quickstart](https://min.io/docs/minio/linux/index.html)
```bash
# Using the MinIO client (mc):
mc alias set local http://YOUR_MINIO_HOST:9000 minioadmin minioadmin
mc mb local/silo-files
mc admin user add local silouser YOUR_MINIO_SECRET
mc admin policy attach local readwrite --user silouser
```
Verify:
```bash
curl -I http://YOUR_MINIO_HOST:9000/minio/health/live
```
#### LDAP / FreeIPA (Optional)
For LDAP authentication, you need an LDAP server with user and group entries. Options:
@@ -339,10 +319,10 @@ The script:
4. Clones the repository
5. Creates the environment file template
To override the default service hostnames:
To override the default database hostname:
```bash
SILO_DB_HOST=db.example.com SILO_MINIO_HOST=s3.example.com sudo -E bash scripts/setup-host.sh
SILO_DB_HOST=db.example.com sudo -E bash scripts/setup-host.sh
```
### B.5 Configure Credentials
@@ -357,10 +337,6 @@ sudo nano /etc/silo/silod.env
# Database
SILO_DB_PASSWORD=your-database-password
# MinIO
SILO_MINIO_ACCESS_KEY=silouser
SILO_MINIO_SECRET_KEY=your-minio-secret
# Authentication
SILO_SESSION_SECRET=generate-a-long-random-string
SILO_ADMIN_USERNAME=admin
@@ -379,7 +355,7 @@ Review the server configuration:
sudo nano /etc/silo/config.yaml
```
Update `database.host`, `storage.endpoint`, `server.base_url`, and authentication settings for your environment. See [CONFIGURATION.md](CONFIGURATION.md) for all options.
Update `database.host`, `storage.filesystem.root_dir`, `server.base_url`, and authentication settings for your environment. See [CONFIGURATION.md](CONFIGURATION.md) for all options.
### B.6 Deploy
@@ -412,10 +388,10 @@ sudo /opt/silo/src/scripts/deploy.sh --restart-only
sudo /opt/silo/src/scripts/deploy.sh --status
```
To override the target host or database host:
To override the target host:
```bash
SILO_DEPLOY_TARGET=silo.example.com SILO_DB_HOST=db.example.com sudo -E scripts/deploy.sh
SILO_DEPLOY_TARGET=silo.example.com sudo -E scripts/deploy.sh
```
### B.7 Set Up Nginx and TLS

485
docs/KC_SERVER.md Normal file
View 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

745
docs/MODULES.md Normal file
View File

@@ -0,0 +1,745 @@
# Module System Specification
**Status:** Draft
**Last Updated:** 2026-02-14
---
## 1. Purpose
Silo's module system defines the boundary between required infrastructure and optional capabilities. Each module groups a set of API endpoints, UI views, and configuration parameters. Modules can be enabled or disabled at runtime by administrators via the web UI, and clients can query which modules are active to adapt their feature set.
The goal: after initial deployment (where `config.yaml` sets database, storage, and server bind), all further operational configuration happens through the admin settings UI. The YAML file becomes the bootstrap; the database becomes the runtime source of truth.
---
## 2. Module Registry
### 2.1 Required Modules
These cannot be disabled. They define what Silo *is*.
| Module ID | Name | Description |
|-----------|------|-------------|
| `core` | Core PDM | Items, revisions, files, BOM, search, import/export, part number generation |
| `schemas` | Schemas | Part numbering schema parsing, segment management, form descriptors |
| `storage` | Storage | Filesystem storage |
### 2.2 Optional Modules
| Module ID | Name | Default | Description |
|-----------|------|---------|-------------|
| `auth` | Authentication | `true` | Local, LDAP, OIDC authentication and RBAC |
| `projects` | Projects | `true` | Project management and item tagging |
| `audit` | Audit | `true` | Audit logging, completeness scoring |
| `odoo` | Odoo ERP | `false` | Odoo integration (config, sync-log, push/pull) |
| `freecad` | Create Integration | `true` | URI scheme, executable path, client settings |
| `jobs` | Job Queue | `false` | Async compute jobs, runner management |
| `dag` | Dependency DAG | `false` | Feature DAG sync, validation states, interference detection |
### 2.3 Module Dependencies
Some modules require others to function:
| Module | Requires |
|--------|----------|
| `dag` | `jobs` |
| `jobs` | `auth` (runner tokens) |
| `odoo` | `auth` |
When enabling a module, its dependencies are validated. The server rejects enabling `dag` without `jobs`. Disabling a module that others depend on shows a warning listing dependents.
---
## 3. Endpoint-to-Module Mapping
### 3.1 `core` (required)
```
# Health
GET /health
GET /ready
# Items
GET /api/items
GET /api/items/search
GET /api/items/by-uuid/{uuid}
GET /api/items/export.csv
GET /api/items/template.csv
GET /api/items/export.ods
GET /api/items/template.ods
POST /api/items
POST /api/items/import
POST /api/items/import.ods
GET /api/items/{partNumber}
PUT /api/items/{partNumber}
DELETE /api/items/{partNumber}
# Revisions
GET /api/items/{partNumber}/revisions
GET /api/items/{partNumber}/revisions/compare
GET /api/items/{partNumber}/revisions/{revision}
POST /api/items/{partNumber}/revisions
PATCH /api/items/{partNumber}/revisions/{revision}
POST /api/items/{partNumber}/revisions/{revision}/rollback
# Files
GET /api/items/{partNumber}/files
GET /api/items/{partNumber}/file
GET /api/items/{partNumber}/file/{revision}
POST /api/items/{partNumber}/file
POST /api/items/{partNumber}/files
DELETE /api/items/{partNumber}/files/{fileId}
PUT /api/items/{partNumber}/thumbnail
POST /api/uploads/presign
# BOM
GET /api/items/{partNumber}/bom
GET /api/items/{partNumber}/bom/expanded
GET /api/items/{partNumber}/bom/flat
GET /api/items/{partNumber}/bom/cost
GET /api/items/{partNumber}/bom/where-used
GET /api/items/{partNumber}/bom/export.csv
GET /api/items/{partNumber}/bom/export.ods
POST /api/items/{partNumber}/bom
POST /api/items/{partNumber}/bom/import
POST /api/items/{partNumber}/bom/merge
PUT /api/items/{partNumber}/bom/{childPartNumber}
DELETE /api/items/{partNumber}/bom/{childPartNumber}
# .kc Metadata
GET /api/items/{partNumber}/metadata
PUT /api/items/{partNumber}/metadata
PATCH /api/items/{partNumber}/metadata/lifecycle
PATCH /api/items/{partNumber}/metadata/tags
# .kc Dependencies
GET /api/items/{partNumber}/dependencies
GET /api/items/{partNumber}/dependencies/resolve
# .kc Macros
GET /api/items/{partNumber}/macros
GET /api/items/{partNumber}/macros/{filename}
# Part Number Generation
POST /api/generate-part-number
# Sheets
POST /api/sheets/diff
# Settings & Modules (admin)
GET /api/modules
GET /api/admin/settings
GET /api/admin/settings/{module}
PUT /api/admin/settings/{module}
POST /api/admin/settings/{module}/test
```
### 3.2 `schemas` (required)
```
GET /api/schemas
GET /api/schemas/{name}
GET /api/schemas/{name}/form
POST /api/schemas/{name}/segments/{segment}/values
PUT /api/schemas/{name}/segments/{segment}/values/{code}
DELETE /api/schemas/{name}/segments/{segment}/values/{code}
```
### 3.3 `storage` (required)
No dedicated endpoints — storage is consumed internally by file upload/download in `core`. Exposed through admin settings for connection status visibility.
### 3.4 `auth`
```
# Public (login flow)
GET /login
POST /login
POST /logout
GET /auth/oidc
GET /auth/callback
# Authenticated
GET /api/auth/me
GET /api/auth/tokens
POST /api/auth/tokens
DELETE /api/auth/tokens/{id}
# Web UI
GET /settings (account info, tokens)
POST /settings/tokens
POST /settings/tokens/{id}/revoke
```
When `auth` is disabled, all routes are open and a synthetic `dev` admin user is injected (current behavior).
### 3.5 `projects`
```
GET /api/projects
GET /api/projects/{code}
GET /api/projects/{code}/items
GET /api/projects/{code}/sheet.ods
POST /api/projects
PUT /api/projects/{code}
DELETE /api/projects/{code}
# Item-project tagging
GET /api/items/{partNumber}/projects
POST /api/items/{partNumber}/projects
DELETE /api/items/{partNumber}/projects/{code}
```
When disabled: project tag endpoints return `404`, project columns are hidden in UI list views, project filter is removed from item search.
### 3.6 `audit`
```
GET /api/audit/completeness
GET /api/audit/completeness/{partNumber}
```
When disabled: audit log table continues to receive writes (it's part of core middleware), but the completeness scoring endpoints and the Audit page in the web UI are hidden. Future: retention policies, export, and compliance reporting endpoints live here.
### 3.7 `odoo`
```
GET /api/integrations/odoo/config
GET /api/integrations/odoo/sync-log
PUT /api/integrations/odoo/config
POST /api/integrations/odoo/test-connection
POST /api/integrations/odoo/sync/push/{partNumber}
POST /api/integrations/odoo/sync/pull/{odooId}
```
### 3.8 `freecad`
No dedicated API endpoints currently. Configures URI scheme and executable path used by the web UI's "Open in Create" links and by CLI operations. Future: client configuration distribution endpoint.
### 3.9 `jobs`
```
# User-facing
GET /api/jobs
GET /api/jobs/{jobID}
GET /api/jobs/{jobID}/logs
POST /api/jobs
POST /api/jobs/{jobID}/cancel
# Job definitions
GET /api/job-definitions
GET /api/job-definitions/{name}
POST /api/job-definitions/reload
# Runner management (admin)
GET /api/runners
POST /api/runners
DELETE /api/runners/{runnerID}
# Runner-facing (runner token auth)
POST /api/runner/heartbeat
POST /api/runner/claim
PUT /api/runner/jobs/{jobID}/progress
POST /api/runner/jobs/{jobID}/complete
POST /api/runner/jobs/{jobID}/fail
POST /api/runner/jobs/{jobID}/log
PUT /api/runner/jobs/{jobID}/dag
```
### 3.10 `dag`
```
GET /api/items/{partNumber}/dag
GET /api/items/{partNumber}/dag/forward-cone/{nodeKey}
GET /api/items/{partNumber}/dag/dirty
PUT /api/items/{partNumber}/dag
POST /api/items/{partNumber}/dag/mark-dirty/{nodeKey}
```
---
## 4. Disabled Module Behavior
When a module is disabled:
1. **API routes** registered by that module return `404 Not Found` with body `{"error": "module '<id>' is not enabled"}`.
2. **Web UI** hides the module's navigation entry, page, and any inline UI elements (e.g., project tags on item cards).
3. **SSE events** from the module are not broadcast.
4. **Background goroutines** (e.g., job timeout sweeper, runner heartbeat checker) are not started.
5. **Database tables** are not dropped — they remain for re-enablement. No data loss on disable/enable cycle.
Implementation: each module's route group is wrapped in a middleware check:
```go
func RequireModule(id string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !modules.IsEnabled(id) {
http.Error(w, `{"error":"module '`+id+`' is not enabled"}`, 404)
return
}
next.ServeHTTP(w, r)
})
}
}
```
---
## 5. Configuration Persistence
### 5.1 Precedence
```
Environment variables (highest — always wins, secrets live here)
Database overrides (admin UI writes here)
config.yaml (lowest — bootstrap defaults)
```
### 5.2 Database Table
```sql
-- Migration 014_settings.sql
CREATE TABLE settings_overrides (
key TEXT PRIMARY KEY, -- dotted path: "auth.ldap.enabled"
value JSONB NOT NULL, -- typed value
updated_by TEXT NOT NULL, -- username
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE module_state (
module_id TEXT PRIMARY KEY, -- "auth", "projects", etc.
enabled BOOLEAN NOT NULL,
updated_by TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
### 5.3 Load Sequence
On startup:
1. Parse `config.yaml` into Go config struct.
2. Query `settings_overrides` — merge each key into the struct using dotted path resolution.
3. Apply environment variable overrides (existing `SILO_*` vars).
4. Query `module_state` — override default enabled/disabled from YAML.
5. Validate module dependencies.
6. Register only enabled modules' route groups.
7. Start only enabled modules' background goroutines.
### 5.4 Runtime Updates
When an admin saves settings via `PUT /api/admin/settings/{module}`:
1. Validate the payload against the module's config schema.
2. Write changed keys to `settings_overrides`.
3. Update `module_state` if `enabled` changed.
4. Apply changes to the in-memory config (hot reload where safe).
5. Broadcast `settings.changed` SSE event with `{module, enabled, changed_keys}`.
6. For changes that require restart (e.g., `server.port`, `database.*`), return a `restart_required: true` flag in the response. The UI shows a banner.
### 5.5 What Requires Restart
| Config Area | Hot Reload | Restart Required |
|-------------|-----------|------------------|
| Module enable/disable | Yes | No |
| `auth.*` provider toggles | Yes | No |
| `auth.cors.allowed_origins` | Yes | No |
| `odoo.*` connection settings | Yes | No |
| `freecad.*` | Yes | No |
| `jobs.*` timeouts, directory | Yes | No |
| `server.host`, `server.port` | No | Yes |
| `database.*` | No | Yes |
| `storage.*` | No | Yes |
| `schemas.directory` | No | Yes |
---
## 6. Public Module Discovery Endpoint
```
GET /api/modules
```
**No authentication required.** Clients need this pre-login to know whether OIDC is available, whether projects exist, etc.
### 6.1 Response
```json
{
"modules": {
"core": {
"enabled": true,
"required": true,
"name": "Core PDM",
"version": "0.2"
},
"schemas": {
"enabled": true,
"required": true,
"name": "Schemas"
},
"storage": {
"enabled": true,
"required": true,
"name": "Storage"
},
"auth": {
"enabled": true,
"required": false,
"name": "Authentication",
"config": {
"local_enabled": true,
"ldap_enabled": true,
"oidc_enabled": true,
"oidc_issuer_url": "https://keycloak.example.com/realms/silo"
}
},
"projects": {
"enabled": true,
"required": false,
"name": "Projects"
},
"audit": {
"enabled": true,
"required": false,
"name": "Audit"
},
"odoo": {
"enabled": false,
"required": false,
"name": "Odoo ERP"
},
"freecad": {
"enabled": true,
"required": false,
"name": "Create Integration",
"config": {
"uri_scheme": "silo"
}
},
"jobs": {
"enabled": false,
"required": false,
"name": "Job Queue"
},
"dag": {
"enabled": false,
"required": false,
"name": "Dependency DAG",
"depends_on": ["jobs"]
}
},
"server": {
"version": "0.2",
"read_only": false
}
}
```
The `config` sub-object exposes only public, non-secret metadata needed by clients. Never includes passwords, tokens, or secret keys.
---
## 7. Admin Settings Endpoints
### 7.1 Get All Settings
```
GET /api/admin/settings
Authorization: Bearer <admin token>
```
Returns full config grouped by module with secrets redacted:
```json
{
"core": {
"server": {
"host": "0.0.0.0",
"port": 8080,
"base_url": "https://silo.example.com",
"read_only": false
}
},
"schemas": {
"directory": "/etc/silo/schemas",
"default": "kindred-rd"
},
"storage": {
"backend": "filesystem",
"filesystem": {
"root_dir": "/var/lib/silo/data"
},
"status": "connected"
},
"database": {
"host": "postgres",
"port": 5432,
"name": "silo",
"user": "silo",
"password": "****",
"sslmode": "disable",
"max_connections": 10,
"status": "connected"
},
"auth": {
"enabled": true,
"session_secret": "****",
"local": { "enabled": true },
"ldap": {
"enabled": true,
"url": "ldaps://ipa.example.com",
"base_dn": "dc=kindred,dc=internal",
"user_search_dn": "cn=users,cn=accounts,dc=kindred,dc=internal",
"bind_password": "****",
"role_mapping": { "...": "..." }
},
"oidc": {
"enabled": true,
"issuer_url": "https://keycloak.example.com/realms/silo",
"client_id": "silo",
"client_secret": "****",
"redirect_url": "https://silo.example.com/auth/callback"
},
"cors": { "allowed_origins": ["https://silo.example.com"] }
},
"projects": { "enabled": true },
"audit": { "enabled": true },
"odoo": { "enabled": false, "url": "", "database": "", "username": "" },
"freecad": { "uri_scheme": "silo", "executable": "" },
"jobs": {
"enabled": false,
"directory": "/etc/silo/jobdefs",
"runner_timeout": 90,
"job_timeout_check": 30,
"default_priority": 100
},
"dag": { "enabled": false }
}
```
### 7.2 Get Module Settings
```
GET /api/admin/settings/{module}
```
Returns just the module's config block.
### 7.3 Update Module Settings
```
PUT /api/admin/settings/{module}
Content-Type: application/json
{
"enabled": true,
"ldap": {
"enabled": true,
"url": "ldaps://ipa.example.com"
}
}
```
**Response:**
```json
{
"updated": ["auth.ldap.enabled", "auth.ldap.url"],
"restart_required": false
}
```
### 7.4 Test Connectivity
```
POST /api/admin/settings/{module}/test
```
Available for modules with external connections:
| Module | Test Action |
|--------|------------|
| `storage` | Verify filesystem storage directory is accessible |
| `auth` (ldap) | Attempt LDAP bind with configured credentials |
| `auth` (oidc) | Fetch OIDC discovery document from issuer URL |
| `odoo` | Attempt XML-RPC connection to Odoo |
**Response:**
```json
{
"success": true,
"message": "LDAP bind successful",
"latency_ms": 42
}
```
---
## 8. Config YAML Changes
The existing `config.yaml` gains a `modules` section. Existing top-level keys remain for backward compatibility — the module system reads from both locations.
```yaml
# Existing keys (unchanged, still work)
server:
host: "0.0.0.0"
port: 8080
database:
host: postgres
port: 5432
name: silo
user: silo
password: silodev
sslmode: disable
storage:
backend: filesystem
filesystem:
root_dir: /var/lib/silo/data
schemas:
directory: /etc/silo/schemas
auth:
enabled: true
session_secret: change-me
local:
enabled: true
# New: explicit module toggles (optional, defaults shown)
modules:
projects:
enabled: true
audit:
enabled: true
odoo:
enabled: false
freecad:
enabled: true
uri_scheme: silo
jobs:
enabled: false
directory: /etc/silo/jobdefs
runner_timeout: 90
job_timeout_check: 30
default_priority: 100
dag:
enabled: false
```
If a module is not listed under `modules:`, its default enabled state from Section 2.2 applies. The `auth.enabled` field continues to control the `auth` module (no duplication under `modules:`).
---
## 9. SSE Events
```
settings.changed {module, enabled, changed_keys[], updated_by}
```
Broadcast on any admin settings change. The web UI listens for this to:
- Show/hide navigation entries when modules are toggled.
- Display a "Settings updated by another admin" toast.
- Show a "Restart required" banner when flagged.
---
## 10. Web UI — Admin Settings Page
The Settings page (`/settings`) is restructured into sections:
### 10.1 Existing (unchanged)
- **Account** — username, display name, email, auth source, role badge.
- **API Tokens** — create, list, revoke.
### 10.2 New: Module Configuration (admin only)
Visible only to admin users. Each module gets a collapsible card:
```
┌─────────────────────────────────────────────────────┐
│ [toggle] Authentication [status] │
├─────────────────────────────────────────────────────┤
│ │
│ ── Local Auth ──────────────────────────────────── │
│ Enabled: [toggle] │
│ │
│ ── LDAP / FreeIPA ──────────────────────────────── │
│ Enabled: [toggle] │
│ URL: [ldaps://ipa.example.com ] │
│ Base DN: [dc=kindred,dc=internal ] [Test] │
│ │
│ ── OIDC / Keycloak ────────────────────────────── │
│ Enabled: [toggle] │
│ Issuer URL: [https://keycloak.example.com] [Test] │
│ Client ID: [silo ] │
│ │
│ ── CORS ────────────────────────────────────────── │
│ Allowed Origins: [tag input] │
│ │
│ [Save] │
└─────────────────────────────────────────────────────┘
```
Module cards for required modules (`core`, `schemas`, `storage`) show their status and config but have no enable/disable toggle.
Status indicators per module:
| Status | Badge | Meaning |
|--------|-------|---------|
| Active | `green` | Enabled and operational |
| Disabled | `overlay1` | Toggled off |
| Error | `red` | Enabled but connectivity or config issue |
| Setup Required | `yellow` | Enabled but missing required config (e.g., LDAP URL empty) |
### 10.3 Infrastructure Section (admin, read-only)
Shows connection status for required infrastructure:
- **Database** — host, port, name, connection pool usage, status badge.
- **Storage** — endpoint, bucket, SSL, status badge.
These are read-only in the UI (setup-only via YAML/env). The "Test" button is available to verify connectivity.
---
## 11. Implementation Order
1. **Migration 014**`settings_overrides` and `module_state` tables.
2. **Config loader refactor** — YAML → DB merge → env override pipeline.
3. **Module registry** — Go struct defining all modules with metadata, dependencies, defaults.
4. **`GET /api/modules`** — public endpoint, no auth.
5. **`RequireModule` middleware** — gate route groups by module state.
6. **Admin settings API**`GET/PUT /api/admin/settings/{module}`, test endpoints.
7. **Web UI settings page** — module cards with toggles, config forms, test buttons.
8. **SSE integration**`settings.changed` event broadcast.
---
## 12. Future Considerations
- **Module manifest format** — per ROADMAP.md, each module will eventually declare routes, views, hooks, and permissions via a manifest. This spec covers the runtime module registry; the manifest format is TBD.
- **Custom modules** — third-party modules that register against the endpoint registry. Requires the manifest contract and a plugin loading mechanism.
- **Per-module permissions** — beyond the current role hierarchy, modules may define fine-grained scopes (e.g., `jobs:admin`, `dag:write`).
- **Location & Inventory module** — when the Location/Inventory API is implemented (tables already exist), it becomes a new optional module.
- **Notifications module** — per ROADMAP.md Tier 1, notifications/subscriptions will be a dedicated module.
---
## 13. References
- [CONFIGURATION.md](CONFIGURATION.md) — Current config reference
- [ROADMAP.md](ROADMAP.md) — Module manifest, API endpoint registry
- [AUTH.md](AUTH.md) — Authentication architecture
- [WORKERS.md](WORKERS.md) — Job queue system
- [DAG.md](DAG.md) — Dependency DAG specification
- [SPECIFICATION.md](SPECIFICATION.md) — Full endpoint listing

View File

@@ -88,7 +88,7 @@ Everything depends on these. They define what Silo *is*.
| Component | Description | Status |
|-----------|-------------|--------|
| **Core Silo** | Part/assembly storage, version control, auth, base REST API | Complete |
| **.kc Format Spec** | File format contract between Create and Silo | Not Started |
| **.kc Format Spec** | File format contract between Create and Silo | Complete |
| **API Endpoint Registry** | Module discovery, dynamic UI rendering, health checks | Not Started |
| **Web UI Shell** | App launcher, breadcrumbs, view framework, module rendering | Partial |
| **Python Scripting Engine** | Server-side hook execution, module extension point | Not Started |
@@ -313,7 +313,7 @@ For full SOLIDWORKS PDM comparison tables, see [GAP_ANALYSIS.md Appendix C](GAP_
- Rollback functionality
#### File Management
- MinIO integration with versioning
- Filesystem-based file storage
- File upload/download via REST API
- SHA256 checksums for integrity
- Storage path: `items/{partNumber}/rev{N}.FCStd`
@@ -377,8 +377,8 @@ For full SOLIDWORKS PDM comparison tables, see [GAP_ANALYSIS.md Appendix C](GAP_
## Appendix B: Phase 1 Detailed Tasks
### 1.1 MinIO Integration -- COMPLETE
- [x] MinIO service configured in Docker Compose
### 1.1 File Storage -- COMPLETE
- [x] Filesystem storage backend
- [x] File upload via REST API
- [x] File download via REST API (latest and by revision)
- [x] SHA256 checksums on upload

View File

@@ -37,7 +37,7 @@ Silo treats **part numbering schemas as configuration, not code**. Multiple numb
┌─────────────────────────────────────────────────────────────┐
│ Silo Server (silod) │
│ - REST API (78 endpoints) │
│ - REST API (86 endpoints) │
│ - Authentication (local, LDAP, OIDC) │
│ - Schema parsing and validation │
│ - Part number generation engine │
@@ -49,9 +49,9 @@ Silo treats **part numbering schemas as configuration, not code**. Multiple numb
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────────┐
│ PostgreSQL │ │ MinIO
│ PostgreSQL │ │ Local Filesystem
│ (psql.example.internal)│ │ - File storage │
│ - Item metadata │ │ - Versioned objects
│ - Item metadata │ │ - Revision files
│ - Relationships │ │ - Thumbnails │
│ - Revision history │ │ │
│ - Auth / Sessions │ │ │
@@ -64,7 +64,7 @@ Silo treats **part numbering schemas as configuration, not code**. Multiple numb
| Component | Technology | Notes |
|-----------|------------|-------|
| Database | PostgreSQL 16 | Existing instance at psql.example.internal |
| File Storage | MinIO | S3-compatible, versioning enabled |
| File Storage | Local filesystem | Files stored under configurable root directory |
| CLI & API Server | Go (1.24) | chi/v5 router, pgx/v5 driver, zerolog |
| Authentication | Multi-backend | Local (bcrypt), LDAP/FreeIPA, OIDC/Keycloak |
| Sessions | PostgreSQL pgxstore | alexedwards/scs, 24h lifetime |
@@ -83,7 +83,7 @@ An **item** is the fundamental entity. Items have:
- **Properties** (key-value pairs, schema-defined and custom)
- **Relationships** to other items
- **Revisions** (append-only history)
- **Files** (optional, stored in MinIO)
- **Files** (optional, stored on the local filesystem)
- **Location** (optional physical inventory location)
### 3.2 Database Schema (Conceptual)
@@ -115,7 +115,7 @@ CREATE TABLE revisions (
item_id UUID REFERENCES items(id) NOT NULL,
revision_number INTEGER NOT NULL,
properties JSONB NOT NULL, -- all properties at this revision
file_version TEXT, -- MinIO version ID if applicable
file_version TEXT, -- storage version ID if applicable
created_at TIMESTAMPTZ DEFAULT now(),
created_by TEXT, -- user identifier (future: LDAP DN)
comment TEXT,
@@ -345,7 +345,7 @@ CAD workbench and spreadsheet extension implementations are maintained in separa
### 5.1 File Storage Strategy
Files are stored as whole objects in MinIO with versioning enabled. Storage path convention: `items/{partNumber}/rev{N}.ext`. SHA-256 checksums are captured on upload for integrity verification.
Files are stored on the local filesystem under a configurable root directory. Storage path convention: `items/{partNumber}/rev{N}.ext`. SHA-256 checksums are captured on upload for integrity verification.
Future option: exploded storage (unpack ZIP-based CAD archives for better diffing).
@@ -439,7 +439,7 @@ Revisions are created explicitly by user action (not automatic):
### 7.3 Revision vs. File Version
- **Revision**: Silo metadata revision (tracked in PostgreSQL)
- **File Version**: MinIO object version (automatic on upload)
- **File Version**: File on disk corresponding to a revision
A single Silo revision may span multiple file uploads during editing. Only committed revisions create formal revision records.
@@ -598,12 +598,12 @@ See [AUTH.md](AUTH.md) for full architecture details and [AUTH_USER_GUIDE.md](AU
## 11. API Design
### 11.1 REST Endpoints (78 Implemented)
### 11.1 REST Endpoints (86 Implemented)
```
# Health (no auth)
GET /health # Basic health check
GET /ready # Readiness (DB + MinIO)
GET /ready # Readiness (DB)
# Auth (no auth required)
GET /login # Login page
@@ -624,8 +624,8 @@ GET /api/auth/tokens # List user's API to
POST /api/auth/tokens # Create API token
DELETE /api/auth/tokens/{id} # Revoke API token
# Presigned Uploads (editor)
POST /api/uploads/presign # Get presigned MinIO upload URL [editor]
# Direct Uploads (editor)
POST /api/uploads/presign # Get upload URL [editor]
# Schemas (read: viewer, write: editor)
GET /api/schemas # List all schemas
@@ -697,6 +697,20 @@ POST /api/items/{partNumber}/bom/merge # Merge BOM from ODS
PUT /api/items/{partNumber}/bom/{childPartNumber} # Update BOM entry [editor]
DELETE /api/items/{partNumber}/bom/{childPartNumber} # Remove BOM entry [editor]
# .kc Metadata (read: viewer, write: editor)
GET /api/items/{partNumber}/metadata # Get indexed .kc metadata
PUT /api/items/{partNumber}/metadata # Update metadata fields [editor]
PATCH /api/items/{partNumber}/metadata/lifecycle # Transition lifecycle state [editor]
PATCH /api/items/{partNumber}/metadata/tags # Add/remove tags [editor]
# .kc Dependencies (viewer)
GET /api/items/{partNumber}/dependencies # List raw dependencies
GET /api/items/{partNumber}/dependencies/resolve # Resolve UUIDs to part numbers + file availability
# .kc Macros (viewer)
GET /api/items/{partNumber}/macros # List registered macros
GET /api/items/{partNumber}/macros/{filename} # Get macro source content
# Audit (viewer)
GET /api/audit/completeness # Item completeness scores
GET /api/audit/completeness/{partNumber} # Item detail breakdown
@@ -735,6 +749,139 @@ POST /api/inventory/{partNumber}/move
---
## 11.3 .kc File Integration
Silo supports the `.kc` file format — a ZIP archive that is a superset of FreeCAD's `.fcstd`. A `.kc` file contains everything an `.fcstd` does, plus a `silo/` directory with platform metadata.
#### Standard entries (preserved as-is)
`Document.xml`, `GuiDocument.xml`, BREP geometry files (`.brp`), `thumbnails/`
#### Silo entries (`silo/` directory)
| Path | Purpose |
|------|---------|
| `silo/manifest.json` | Instance origin, part UUID, revision hash, `.kc` schema version |
| `silo/metadata.json` | Custom schema field values, tags, lifecycle state |
| `silo/history.json` | Local revision log (server-generated on checkout) |
| `silo/dependencies.json` | Assembly link references by Silo UUID |
| `silo/macros/*.py` | Embedded macro scripts bound to this part |
#### Commit-time extraction
When a `.kc` file is uploaded via `POST /api/items/{partNumber}/file`, the server:
1. Opens the ZIP and scans for `silo/` entries
2. Parses `silo/manifest.json` and validates the UUID matches the item
3. Upserts `silo/metadata.json` fields into the `item_metadata` table
4. Replaces `silo/dependencies.json` entries in the `item_dependencies` table
5. Replaces `silo/macros/*.py` entries in the `item_macros` table
6. Broadcasts SSE events: `metadata.updated`, `dependencies.changed`, `macros.changed`
Extraction is best-effort — failures are logged as warnings but do not block the upload.
#### Checkout-time packing
When a `.kc` file is downloaded via `GET /api/items/{partNumber}/file/{revision}`, the server repacks the `silo/` directory with current database state:
- `silo/manifest.json` — current item UUID and metadata freshness
- `silo/metadata.json` — latest schema fields, tags, lifecycle state
- `silo/history.json` — last 20 revisions from the database
- `silo/dependencies.json` — current dependency list from `item_dependencies`
Non-silo ZIP entries are passed through unchanged. If the file is a plain `.fcstd` (no `silo/` directory), it is served as-is.
ETag caching: the server computes an ETag from `revision_number:metadata.updated_at` and returns `304 Not Modified` when the client's `If-None-Match` header matches.
#### Lifecycle state machine
The `lifecycle_state` field in `item_metadata` follows this state machine:
```
draft → review → released → obsolete
↑ ↓
└────────┘
```
Valid transitions are enforced by `PATCH /metadata/lifecycle`. Invalid transitions return `422 Unprocessable Entity`.
#### Metadata response shape
```json
{
"schema_name": "kindred-rd",
"lifecycle_state": "draft",
"tags": ["prototype", "v2"],
"fields": {"material": "AL6061", "finish": "anodized"},
"manifest": {
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"silo_instance": "silo.example.com",
"revision_hash": "abc123",
"kc_version": "1.0"
},
"updated_at": "2026-02-18T12:00:00Z",
"updated_by": "forbes"
}
```
#### Dependency response shape
```json
[
{
"uuid": "550e8400-...",
"part_number": "F01-0042",
"revision": 3,
"quantity": 4.0,
"label": "M5 Bolt",
"relationship": "component"
}
]
```
#### Resolved dependency response shape
```json
[
{
"uuid": "550e8400-...",
"part_number": "F01-0042",
"label": "M5 Bolt",
"revision": 3,
"quantity": 4.0,
"resolved": true,
"file_available": true
}
]
```
#### Macro list response shape
```json
[
{"filename": "validate_dims.py", "trigger": "manual", "revision_number": 5}
]
```
#### Macro detail response shape
```json
{
"filename": "validate_dims.py",
"trigger": "manual",
"content": "import FreeCAD\n...",
"revision_number": 5
}
```
#### Database tables (migration 018)
- `item_metadata` — schema fields, lifecycle state, tags, manifest info
- `item_dependencies` — parent/child UUID references with quantity and relationship type
- `item_macros` — filename, trigger type, source content, indexed per item
---
## 12. MVP Scope
### 12.1 Implemented
@@ -743,8 +890,8 @@ POST /api/inventory/{partNumber}/move
- [x] YAML schema parser for part numbering
- [x] Part number generation engine
- [x] CLI tool (`cmd/silo`)
- [x] API server (`cmd/silod`) with 78 endpoints
- [x] MinIO integration for file storage with versioning
- [x] API server (`cmd/silod`) with 86 endpoints
- [x] Filesystem-based file storage
- [x] BOM relationships (component, alternate, reference)
- [x] Multi-level BOM (recursive expansion with configurable depth)
- [x] Where-used queries (reverse parent lookup)
@@ -765,6 +912,12 @@ POST /api/inventory/{partNumber}/move
- [x] Audit logging and completeness scoring
- [x] CSRF protection (nosurf)
- [x] Fuzzy search
- [x] .kc file extraction pipeline (metadata, dependencies, macros indexed on commit)
- [x] .kc file packing on checkout (manifest, metadata, history, dependencies)
- [x] .kc metadata API (get, update fields, lifecycle transitions, tags)
- [x] .kc dependency API (list, resolve with file availability)
- [x] .kc macro API (list, get source content)
- [x] ETag caching for .kc file downloads
- [x] Property schema versioning framework
- [x] Docker Compose deployment (dev and prod)
- [x] systemd service and deployment scripts

View File

@@ -10,12 +10,12 @@
| Component | Status | Notes |
|-----------|--------|-------|
| PostgreSQL schema | Complete | 13 migrations applied |
| PostgreSQL schema | Complete | 18 migrations applied |
| YAML schema parser | Complete | Supports enum, serial, constant, string segments |
| Part number generator | Complete | Scoped sequences, category-based format |
| API server (`silod`) | Complete | 78 REST endpoints via chi/v5 |
| API server (`silod`) | Complete | 86 REST endpoints via chi/v5 |
| CLI tool (`silo`) | Complete | Item registration and management |
| MinIO file storage | Complete | Upload, download, versioning, checksums |
| Filesystem file storage | Complete | Upload, download, checksums |
| Revision control | Complete | Append-only history, rollback, comparison, status/labels |
| Project management | Complete | CRUD, many-to-many item tagging |
| CSV import/export | Complete | Dry-run validation, template generation |
@@ -29,7 +29,12 @@
| CSRF protection | Complete | nosurf on web forms |
| Fuzzy search | Complete | sahilm/fuzzy library |
| Web UI | Complete | React SPA (Vite + TypeScript), 6 pages, Catppuccin Mocha theme |
| File attachments | Complete | Presigned uploads, item file association, thumbnails |
| File attachments | Complete | Direct uploads, item file association, thumbnails |
| .kc extraction pipeline | Complete | Metadata, dependencies, macros indexed on commit |
| .kc checkout packing | Complete | Manifest, metadata, history, dependencies repacked on download |
| .kc metadata API | Complete | GET/PUT metadata, lifecycle transitions, tag management |
| .kc dependency API | Complete | List raw deps, resolve UUIDs to part numbers + file availability |
| .kc macro API | Complete | List macros, get source content by filename |
| Odoo ERP integration | Partial | Config and sync-log CRUD functional; push/pull are stubs |
| Docker Compose | Complete | Dev and production configurations |
| Deployment scripts | Complete | setup-host, deploy, init-db, setup-ipa-nginx |
@@ -56,7 +61,7 @@ FreeCAD workbench and LibreOffice Calc extension are maintained in separate repo
| Service | Host | Status |
|---------|------|--------|
| PostgreSQL | psql.example.internal:5432 | Running |
| MinIO | localhost:9000 (API) / :9001 (console) | Configured |
| File Storage | /opt/silo/data (filesystem) | Configured |
| Silo API | localhost:8080 | Builds successfully |
---
@@ -96,3 +101,8 @@ The schema defines 170 category codes across 10 groups:
| 011_item_files.sql | Item file attachments (item_files table, thumbnail_key column) |
| 012_bom_source.sql | BOM entry source tracking |
| 013_move_cost_sourcing_to_props.sql | Move sourcing_link and standard_cost from item columns to revision properties |
| 014_settings.sql | Settings overrides and module state tables |
| 015_jobs.sql | Job queue, runner, and job log tables |
| 016_dag.sql | Dependency DAG nodes and edges |
| 017_locations.sql | Location hierarchy and inventory tracking |
| 018_kc_metadata.sql | .kc metadata tables (item_metadata, item_dependencies, item_macros, item_approvals, approval_signatures) |

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View 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)
}

View File

@@ -43,6 +43,7 @@ func newAuthTestServer(t *testing.T) *Server {
"", // jobDefsDir
modules.NewRegistry(), // modules
nil, // cfg
nil, // workflows
)
}

View File

@@ -285,6 +285,8 @@ func (s *Server) HandleAddBOMEntry(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusCreated, entry)
go s.triggerJobs(context.Background(), "bom_changed", parent.ID, parent)
}
// HandleUpdateBOMEntry updates an existing BOM relationship.
@@ -353,6 +355,8 @@ func (s *Server) HandleUpdateBOMEntry(w http.ResponseWriter, r *http.Request) {
return
}
go s.triggerJobs(context.Background(), "bom_changed", parent.ID, parent)
// Reload and return updated entry
entries, err := s.relationships.GetBOM(ctx, parent.ID)
if err == nil {
@@ -419,6 +423,8 @@ func (s *Server) HandleDeleteBOMEntry(w http.ResponseWriter, r *http.Request) {
Msg("BOM entry removed")
w.WriteHeader(http.StatusNoContent)
go s.triggerJobs(context.Background(), "bom_changed", parent.ID, parent)
}
// Helper functions

View File

@@ -40,6 +40,7 @@ func newTestServer(t *testing.T) *Server {
"", // jobDefsDir
modules.NewRegistry(), // modules
nil, // cfg
nil, // workflows
)
}

View File

@@ -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()

View File

@@ -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)
}
}

View File

@@ -69,6 +69,7 @@ func newTestServerWithSchemas(t *testing.T) *Server {
"", // jobDefsDir
modules.NewRegistry(), // modules
nil, // cfg
nil, // workflows
)
}

View File

@@ -31,6 +31,7 @@ func newDAGTestServer(t *testing.T) *Server {
broker, state,
nil, "",
modules.NewRegistry(), nil,
nil,
)
}

View File

@@ -0,0 +1,125 @@
package api
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/kindredsystems/silo/internal/storage"
)
// DependencyResponse is the JSON representation for GET /dependencies.
type DependencyResponse struct {
UUID string `json:"uuid"`
PartNumber *string `json:"part_number"`
Revision *int `json:"revision"`
Quantity *float64 `json:"quantity"`
Label *string `json:"label"`
Relationship string `json:"relationship"`
}
// ResolvedDependencyResponse is the JSON representation for GET /dependencies/resolve.
type ResolvedDependencyResponse struct {
UUID string `json:"uuid"`
PartNumber *string `json:"part_number"`
Label *string `json:"label"`
Revision *int `json:"revision"`
Quantity *float64 `json:"quantity"`
Resolved bool `json:"resolved"`
FileAvailable bool `json:"file_available"`
}
// HandleGetDependencies returns the raw dependency list for an item.
// GET /api/items/{partNumber}/dependencies
func (s *Server) HandleGetDependencies(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
}
deps, err := s.deps.ListByItem(ctx, item.ID)
if err != nil {
s.logger.Error().Err(err).Msg("failed to list dependencies")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list dependencies")
return
}
resp := make([]DependencyResponse, len(deps))
for i, d := range deps {
resp[i] = DependencyResponse{
UUID: d.ChildUUID,
PartNumber: d.ChildPartNumber,
Revision: d.ChildRevision,
Quantity: d.Quantity,
Label: d.Label,
Relationship: d.Relationship,
}
}
writeJSON(w, http.StatusOK, resp)
}
// HandleResolveDependencies returns dependencies with UUIDs resolved to part numbers
// and file availability status.
// GET /api/items/{partNumber}/dependencies/resolve
func (s *Server) HandleResolveDependencies(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
}
deps, err := s.deps.Resolve(ctx, item.ID)
if err != nil {
s.logger.Error().Err(err).Msg("failed to resolve dependencies")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve dependencies")
return
}
resp := make([]ResolvedDependencyResponse, len(deps))
for i, d := range deps {
// Use resolved part number if available, fall back to .kc-provided value.
pn := d.ChildPartNumber
rev := d.ChildRevision
if d.Resolved {
pn = d.ResolvedPartNumber
rev = d.ResolvedRevision
}
fileAvailable := false
if d.Resolved && pn != nil && rev != nil && s.storage != nil {
key := storage.FileKey(*pn, *rev)
if exists, err := s.storage.Exists(ctx, key); err == nil {
fileAvailable = exists
}
}
resp[i] = ResolvedDependencyResponse{
UUID: d.ChildUUID,
PartNumber: pn,
Label: d.Label,
Revision: rev,
Quantity: d.Quantity,
Resolved: d.Resolved,
FileAvailable: fileAvailable,
}
}
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -3,7 +3,9 @@ package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
@@ -19,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")
@@ -314,3 +316,185 @@ func (s *Server) HandleSetItemThumbnail(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusNoContent)
}
// storageBackend returns the configured storage backend name.
func (s *Server) storageBackend() string {
return "filesystem"
}
// HandleUploadItemFile accepts a multipart file upload and stores it as an item attachment.
func (s *Server) HandleUploadItemFile(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
if s.storage == nil {
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured")
return
}
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
}
// Parse multipart form (max 500MB)
if err := r.ParseMultipartForm(500 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid_form", err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "missing_file", "File is required")
return
}
defer file.Close()
contentType := header.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
// Generate permanent key
fileID := uuid.New().String()
permanentKey := fmt.Sprintf("items/%s/files/%s/%s", item.ID, fileID, header.Filename)
// Write directly to storage
result, err := s.storage.Put(ctx, permanentKey, file, header.Size, contentType)
if err != nil {
s.logger.Error().Err(err).Msg("failed to upload file")
writeError(w, http.StatusInternalServerError, "upload_failed", "Failed to store file")
return
}
// Create DB record
itemFile := &db.ItemFile{
ItemID: item.ID,
Filename: header.Filename,
ContentType: contentType,
Size: result.Size,
ObjectKey: permanentKey,
StorageBackend: s.storageBackend(),
}
if err := s.itemFiles.Create(ctx, itemFile); err != nil {
s.logger.Error().Err(err).Msg("failed to create item file record")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save file record")
return
}
s.logger.Info().
Str("part_number", partNumber).
Str("file_id", itemFile.ID).
Str("filename", header.Filename).
Int64("size", result.Size).
Msg("file uploaded to item")
writeJSON(w, http.StatusCreated, itemFileToResponse(itemFile))
}
// HandleUploadItemThumbnail accepts a multipart file upload and sets it as the item thumbnail.
func (s *Server) HandleUploadItemThumbnail(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
if s.storage == nil {
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured")
return
}
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
}
// Parse multipart form (max 10MB for thumbnails)
if err := r.ParseMultipartForm(10 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid_form", err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "missing_file", "File is required")
return
}
defer file.Close()
contentType := header.Header.Get("Content-Type")
if contentType == "" {
contentType = "image/png"
}
thumbnailKey := fmt.Sprintf("items/%s/thumbnail.png", item.ID)
if _, err := s.storage.Put(ctx, thumbnailKey, file, header.Size, contentType); err != nil {
s.logger.Error().Err(err).Msg("failed to upload thumbnail")
writeError(w, http.StatusInternalServerError, "upload_failed", "Failed to store thumbnail")
return
}
if err := s.items.SetThumbnailKey(ctx, item.ID, thumbnailKey); err != nil {
s.logger.Error().Err(err).Msg("failed to update thumbnail key")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save thumbnail")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleDownloadItemFile streams an item file attachment to the client.
func (s *Server) HandleDownloadItemFile(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
fileID := chi.URLParam(r, "fileId")
if s.storage == nil {
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured")
return
}
item, err := s.items.GetByPartNumber(ctx, partNumber)
if err != nil || item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
file, err := s.itemFiles.Get(ctx, fileID)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "File not found")
return
}
if file.ItemID != item.ID {
writeError(w, http.StatusNotFound, "not_found", "File not found")
return
}
reader, err := s.storage.Get(ctx, file.ObjectKey)
if err != nil {
s.logger.Error().Err(err).Str("key", file.ObjectKey).Msg("failed to get file")
writeError(w, http.StatusInternalServerError, "download_failed", "Failed to retrieve file")
return
}
defer reader.Close()
w.Header().Set("Content-Type", file.ContentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, file.Filename))
if file.Size > 0 {
w.Header().Set("Content-Length", strconv.FormatInt(file.Size, 10))
}
io.Copy(w, reader)
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
@@ -19,10 +20,12 @@ import (
"github.com/kindredsystems/silo/internal/config"
"github.com/kindredsystems/silo/internal/db"
"github.com/kindredsystems/silo/internal/jobdef"
"github.com/kindredsystems/silo/internal/kc"
"github.com/kindredsystems/silo/internal/modules"
"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"
)
@@ -37,7 +40,7 @@ type Server struct {
schemas map[string]*schema.Schema
schemasDir string
partgen *partnum.Generator
storage *storage.Storage
storage storage.FileStore
auth *auth.Service
sessions *scs.SessionManager
oidc *auth.OIDCBackend
@@ -47,11 +50,20 @@ type Server struct {
serverState *ServerState
dag *db.DAGRepository
jobs *db.JobRepository
locations *db.LocationRepository
jobDefs map[string]*jobdef.Definition
jobDefsDir string
modules *modules.Registry
cfg *config.Config
settings *db.SettingsRepository
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.
@@ -60,7 +72,7 @@ func NewServer(
database *db.DB,
schemas map[string]*schema.Schema,
schemasDir string,
store *storage.Storage,
store storage.FileStore,
authService *auth.Service,
sessionManager *scs.SessionManager,
oidcBackend *auth.OIDCBackend,
@@ -71,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)
@@ -79,6 +92,14 @@ func NewServer(
dag := db.NewDAGRepository(database)
jobs := db.NewJobRepository(database)
settings := db.NewSettingsRepository(database)
locations := db.NewLocationRepository(database)
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)
@@ -101,11 +122,20 @@ func NewServer(
serverState: state,
dag: dag,
jobs: jobs,
locations: locations,
jobDefs: jobDefs,
jobDefsDir: jobDefsDir,
modules: registry,
cfg: cfg,
settings: settings,
metadata: metadata,
deps: itemDeps,
macros: itemMacros,
approvals: itemApprovals,
workflows: workflows,
solverResults: solverResults,
workstations: workstations,
editSessions: editSessions,
}
}
@@ -1649,10 +1679,14 @@ func (s *Server) HandleUploadFile(w http.ResponseWriter, r *http.Request) {
Int64("size", result.Size).
Msg("file uploaded")
// .kc metadata extraction (best-effort)
s.extractKCMetadata(ctx, item, fileKey, rev)
writeJSON(w, http.StatusCreated, revisionToResponse(rev))
}
// HandleDownloadFile downloads the file for a specific revision.
// For .kc files, silo/ entries are repacked with current DB state.
func (s *Server) HandleDownloadFile(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
@@ -1707,18 +1741,23 @@ func (s *Server) HandleDownloadFile(w http.ResponseWriter, r *http.Request) {
return
}
// Get file from storage
var reader interface {
Read(p []byte) (n int, err error)
Close() error
// ETag: computed from revision + metadata freshness.
meta, _ := s.metadata.Get(ctx, item.ID) // nil is ok (plain .fcstd)
etag := computeETag(revision, meta)
if match := r.Header.Get("If-None-Match"); match == etag {
w.Header().Set("ETag", etag)
w.WriteHeader(http.StatusNotModified)
return
}
// Get file from storage
var reader io.ReadCloser
if revision.FileVersion != nil && *revision.FileVersion != "" {
reader, err = s.storage.GetVersion(ctx, *revision.FileKey, *revision.FileVersion)
} else {
reader, err = s.storage.Get(ctx, *revision.FileKey)
}
if err != nil {
s.logger.Error().Err(err).Str("key", *revision.FileKey).Msg("failed to get file")
writeError(w, http.StatusInternalServerError, "download_failed", err.Error())
@@ -1726,28 +1765,37 @@ func (s *Server) HandleDownloadFile(w http.ResponseWriter, r *http.Request) {
}
defer reader.Close()
// Read entire file for potential .kc repacking.
data, err := io.ReadAll(reader)
if err != nil {
s.logger.Error().Err(err).Msg("failed to read file")
writeError(w, http.StatusInternalServerError, "download_failed", "Failed to read file")
return
}
// Repack silo/ entries for .kc files with indexed metadata.
output := data
if meta != nil {
if hasSilo, chkErr := kc.HasSiloDir(data); chkErr == nil && hasSilo {
if !canSkipRepack(revision, meta) {
if packed, packErr := s.packKCFile(ctx, data, item, revision, meta); packErr != nil {
s.logger.Warn().Err(packErr).Str("part_number", partNumber).Msg("kc: packing failed, serving original")
} else {
output = packed
}
}
}
}
// Set response headers
filename := partNumber + "_rev" + strconv.Itoa(revNum) + ".FCStd"
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
if revision.FileSize != nil {
w.Header().Set("Content-Length", strconv.FormatInt(*revision.FileSize, 10))
}
w.Header().Set("Content-Length", strconv.Itoa(len(output)))
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "private, must-revalidate")
// Stream file to response
buf := make([]byte, 32*1024)
for {
n, readErr := reader.Read(buf)
if n > 0 {
if _, writeErr := w.Write(buf[:n]); writeErr != nil {
s.logger.Error().Err(writeErr).Msg("failed to write response")
return
}
}
if readErr != nil {
break
}
}
w.Write(output)
}
// HandleDownloadLatestFile downloads the file for the latest revision.

View File

@@ -326,6 +326,10 @@ func (s *Server) HandleDeleteRunner(w http.ResponseWriter, r *http.Request) {
// triggerJobs creates jobs for all enabled definitions matching the trigger type.
// It applies trigger filters (e.g. item_type) before creating each job.
func (s *Server) triggerJobs(ctx context.Context, triggerType string, itemID string, item *db.Item) {
if !s.modules.IsEnabled("jobs") {
return
}
defs, err := s.jobs.GetDefinitionsByTrigger(ctx, triggerType)
if err != nil {
s.logger.Error().Err(err).Str("trigger", triggerType).Msg("failed to get job definitions for trigger")

View File

@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/kindredsystems/silo/internal/db"
@@ -31,6 +32,7 @@ func newJobTestServer(t *testing.T) *Server {
broker, state,
nil, "",
modules.NewRegistry(), nil,
nil,
)
}
@@ -319,6 +321,260 @@ func TestHandleDeleteRunner(t *testing.T) {
}
}
// --- Trigger integration tests ---
// newTriggerRouter builds a router with items, revisions, BOM, and jobs routes
// so that HTTP-based actions can fire triggerJobs via goroutine.
func newTriggerRouter(s *Server) http.Handler {
r := chi.NewRouter()
r.Route("/api/items", func(r chi.Router) {
r.Post("/", s.HandleCreateItem)
r.Route("/{partNumber}", func(r chi.Router) {
r.Post("/revisions", s.HandleCreateRevision)
r.Post("/bom", s.HandleAddBOMEntry)
r.Put("/bom/{childPartNumber}", s.HandleUpdateBOMEntry)
r.Delete("/bom/{childPartNumber}", s.HandleDeleteBOMEntry)
})
})
r.Route("/api/jobs", func(r chi.Router) {
r.Get("/", s.HandleListJobs)
})
return r
}
func waitForJobs(t *testing.T, s *Server, itemID string, wantCount int) []*db.Job {
t.Helper()
// triggerJobs runs in a goroutine; poll up to 2 seconds.
for i := 0; i < 20; i++ {
jobs, err := s.jobs.ListJobs(context.Background(), "", itemID, 50, 0)
if err != nil {
t.Fatalf("listing jobs: %v", err)
}
if len(jobs) >= wantCount {
return jobs
}
time.Sleep(100 * time.Millisecond)
}
jobs, _ := s.jobs.ListJobs(context.Background(), "", itemID, 50, 0)
return jobs
}
func TestTriggerJobsOnRevisionCreate(t *testing.T) {
s := newJobTestServer(t)
if err := s.modules.SetEnabled("jobs", true); err != nil {
t.Fatalf("enabling jobs module: %v", err)
}
router := newTriggerRouter(s)
// Create an item.
createItemDirect(t, s, "TRIG-REV-001", "trigger test item", nil)
// Seed a job definition that triggers on revision_created.
def := &db.JobDefinitionRecord{
Name: "rev-trigger-test",
Version: 1,
TriggerType: "revision_created",
ScopeType: "item",
ComputeType: "validate",
RunnerTags: []string{"test"},
TimeoutSeconds: 60,
MaxRetries: 0,
Priority: 100,
Enabled: true,
}
if err := s.jobs.UpsertDefinition(context.Background(), def); err != nil {
t.Fatalf("seeding definition: %v", err)
}
// Create a revision via HTTP (fires triggerJobs in goroutine).
body := `{"properties":{"material":"steel"},"comment":"trigger test"}`
req := authRequest(httptest.NewRequest("POST", "/api/items/TRIG-REV-001/revisions", strings.NewReader(body)))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create revision: expected 201, got %d: %s", w.Code, w.Body.String())
}
// Get the item ID to filter jobs.
item, _ := s.items.GetByPartNumber(context.Background(), "TRIG-REV-001")
if item == nil {
t.Fatal("item not found after creation")
}
jobs := waitForJobs(t, s, item.ID, 1)
if len(jobs) == 0 {
t.Fatal("expected at least 1 triggered job, got 0")
}
if jobs[0].DefinitionName != "rev-trigger-test" {
t.Errorf("expected definition name rev-trigger-test, got %s", jobs[0].DefinitionName)
}
}
func TestTriggerJobsOnBOMChange(t *testing.T) {
s := newJobTestServer(t)
if err := s.modules.SetEnabled("jobs", true); err != nil {
t.Fatalf("enabling jobs module: %v", err)
}
router := newTriggerRouter(s)
// Create parent and child items.
createItemDirect(t, s, "TRIG-BOM-P", "parent", nil)
createItemDirect(t, s, "TRIG-BOM-C", "child", nil)
// Seed a bom_changed job definition.
def := &db.JobDefinitionRecord{
Name: "bom-trigger-test",
Version: 1,
TriggerType: "bom_changed",
ScopeType: "item",
ComputeType: "validate",
RunnerTags: []string{"test"},
TimeoutSeconds: 60,
MaxRetries: 0,
Priority: 100,
Enabled: true,
}
if err := s.jobs.UpsertDefinition(context.Background(), def); err != nil {
t.Fatalf("seeding definition: %v", err)
}
// Add a BOM entry via HTTP.
body := `{"child_part_number":"TRIG-BOM-C","rel_type":"component","quantity":2}`
req := authRequest(httptest.NewRequest("POST", "/api/items/TRIG-BOM-P/bom", strings.NewReader(body)))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("add BOM entry: expected 201, got %d: %s", w.Code, w.Body.String())
}
// Get the parent item ID.
parent, _ := s.items.GetByPartNumber(context.Background(), "TRIG-BOM-P")
if parent == nil {
t.Fatal("parent item not found")
}
jobs := waitForJobs(t, s, parent.ID, 1)
if len(jobs) == 0 {
t.Fatal("expected at least 1 triggered job, got 0")
}
if jobs[0].DefinitionName != "bom-trigger-test" {
t.Errorf("expected definition name bom-trigger-test, got %s", jobs[0].DefinitionName)
}
}
func TestTriggerJobsFilterMismatch(t *testing.T) {
s := newJobTestServer(t)
if err := s.modules.SetEnabled("jobs", true); err != nil {
t.Fatalf("enabling jobs module: %v", err)
}
router := newTriggerRouter(s)
// Create a "part" type item (not "assembly").
createItemDirect(t, s, "TRIG-FILT-P", "filter parent", nil)
createItemDirect(t, s, "TRIG-FILT-C", "filter child", nil)
// Seed a definition that only triggers for assembly items.
def := &db.JobDefinitionRecord{
Name: "assembly-only-test",
Version: 1,
TriggerType: "bom_changed",
ScopeType: "item",
ComputeType: "validate",
RunnerTags: []string{"test"},
TimeoutSeconds: 60,
MaxRetries: 0,
Priority: 100,
Enabled: true,
Definition: map[string]any{
"trigger": map[string]any{
"filter": map[string]any{
"item_type": "assembly",
},
},
},
}
if err := s.jobs.UpsertDefinition(context.Background(), def); err != nil {
t.Fatalf("seeding definition: %v", err)
}
// Add a BOM entry on a "part" item (should NOT match assembly filter).
body := `{"child_part_number":"TRIG-FILT-C","rel_type":"component","quantity":1}`
req := authRequest(httptest.NewRequest("POST", "/api/items/TRIG-FILT-P/bom", strings.NewReader(body)))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("add BOM entry: expected 201, got %d: %s", w.Code, w.Body.String())
}
// Wait briefly, then verify no jobs were created.
parent, _ := s.items.GetByPartNumber(context.Background(), "TRIG-FILT-P")
time.Sleep(500 * time.Millisecond)
jobs, err := s.jobs.ListJobs(context.Background(), "", parent.ID, 50, 0)
if err != nil {
t.Fatalf("listing jobs: %v", err)
}
if len(jobs) != 0 {
t.Errorf("expected 0 jobs (filter mismatch), got %d", len(jobs))
}
}
func TestTriggerJobsModuleDisabled(t *testing.T) {
s := newJobTestServer(t)
// Jobs module is disabled by default in NewRegistry().
router := newTriggerRouter(s)
// Create items.
createItemDirect(t, s, "TRIG-DIS-P", "disabled parent", nil)
createItemDirect(t, s, "TRIG-DIS-C", "disabled child", nil)
// Seed a bom_changed definition (it exists in DB but module is off).
def := &db.JobDefinitionRecord{
Name: "disabled-trigger-test",
Version: 1,
TriggerType: "bom_changed",
ScopeType: "item",
ComputeType: "validate",
RunnerTags: []string{"test"},
TimeoutSeconds: 60,
MaxRetries: 0,
Priority: 100,
Enabled: true,
}
if err := s.jobs.UpsertDefinition(context.Background(), def); err != nil {
t.Fatalf("seeding definition: %v", err)
}
// Add a BOM entry with jobs module disabled.
body := `{"child_part_number":"TRIG-DIS-C","rel_type":"component","quantity":1}`
req := authRequest(httptest.NewRequest("POST", "/api/items/TRIG-DIS-P/bom", strings.NewReader(body)))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("add BOM entry: expected 201, got %d: %s", w.Code, w.Body.String())
}
// Wait briefly, then verify no jobs were created.
parent, _ := s.items.GetByPartNumber(context.Background(), "TRIG-DIS-P")
time.Sleep(500 * time.Millisecond)
jobs, err := s.jobs.ListJobs(context.Background(), "", parent.ID, 50, 0)
if err != nil {
t.Fatalf("listing jobs: %v", err)
}
if len(jobs) != 0 {
t.Errorf("expected 0 jobs (module disabled), got %d", len(jobs))
}
}
func TestGenerateRunnerToken(t *testing.T) {
raw, hash, prefix := generateRunnerToken()

View File

@@ -0,0 +1,234 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/kindredsystems/silo/internal/db"
)
// LocationResponse is the API representation of a location.
type LocationResponse struct {
ID string `json:"id"`
Path string `json:"path"`
Name string `json:"name"`
ParentID *string `json:"parent_id,omitempty"`
LocationType string `json:"location_type"`
Depth int `json:"depth"`
Metadata map[string]any `json:"metadata,omitempty"`
CreatedAt string `json:"created_at"`
}
// CreateLocationRequest represents a request to create a location.
type CreateLocationRequest struct {
Path string `json:"path"`
Name string `json:"name"`
LocationType string `json:"location_type"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// UpdateLocationRequest represents a request to update a location.
type UpdateLocationRequest struct {
Name string `json:"name"`
LocationType string `json:"location_type"`
Metadata map[string]any `json:"metadata,omitempty"`
}
func locationToResponse(loc *db.Location) LocationResponse {
return LocationResponse{
ID: loc.ID,
Path: loc.Path,
Name: loc.Name,
ParentID: loc.ParentID,
LocationType: loc.LocationType,
Depth: loc.Depth,
Metadata: loc.Metadata,
CreatedAt: loc.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}
// HandleListLocations lists all locations. If ?tree={path} is set, returns that
// subtree. If ?root=true, returns only root-level locations (depth 0).
func (s *Server) HandleListLocations(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
treePath := r.URL.Query().Get("tree")
if treePath != "" {
locs, err := s.locations.GetTree(ctx, treePath)
if err != nil {
s.logger.Error().Err(err).Str("tree", treePath).Msg("failed to get location tree")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get location tree")
return
}
writeJSON(w, http.StatusOK, locationsToResponse(locs))
return
}
locs, err := s.locations.List(ctx)
if err != nil {
s.logger.Error().Err(err).Msg("failed to list locations")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list locations")
return
}
writeJSON(w, http.StatusOK, locationsToResponse(locs))
}
// HandleCreateLocation creates a new location.
func (s *Server) HandleCreateLocation(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req CreateLocationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
if req.Path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Path is required")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Name is required")
return
}
if req.LocationType == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Location type is required")
return
}
// Normalize: trim slashes
req.Path = strings.Trim(req.Path, "/")
loc := &db.Location{
Path: req.Path,
Name: req.Name,
LocationType: req.LocationType,
Metadata: req.Metadata,
}
if loc.Metadata == nil {
loc.Metadata = map[string]any{}
}
if err := s.locations.Create(ctx, loc); err != nil {
if strings.Contains(err.Error(), "parent location") || strings.Contains(err.Error(), "does not exist") {
writeError(w, http.StatusBadRequest, "invalid_parent", err.Error())
return
}
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique") {
writeError(w, http.StatusConflict, "already_exists", "Location path already exists")
return
}
s.logger.Error().Err(err).Str("path", req.Path).Msg("failed to create location")
writeError(w, http.StatusInternalServerError, "create_failed", err.Error())
return
}
writeJSON(w, http.StatusCreated, locationToResponse(loc))
}
// HandleGetLocation retrieves a location by path. The path is the rest of the
// URL after /api/locations/, which chi captures as a wildcard.
func (s *Server) HandleGetLocation(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
path := strings.Trim(chi.URLParam(r, "*"), "/")
if path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Location path is required")
return
}
loc, err := s.locations.GetByPath(ctx, path)
if err != nil {
s.logger.Error().Err(err).Str("path", path).Msg("failed to get location")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get location")
return
}
if loc == nil {
writeError(w, http.StatusNotFound, "not_found", "Location not found")
return
}
writeJSON(w, http.StatusOK, locationToResponse(loc))
}
// HandleUpdateLocation updates a location by path.
func (s *Server) HandleUpdateLocation(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
path := strings.Trim(chi.URLParam(r, "*"), "/")
if path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Location path is required")
return
}
var req UpdateLocationRequest
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, "invalid_request", "Name is required")
return
}
if req.LocationType == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Location type is required")
return
}
meta := req.Metadata
if meta == nil {
meta = map[string]any{}
}
if err := s.locations.Update(ctx, path, req.Name, req.LocationType, meta); err != nil {
if strings.Contains(err.Error(), "not found") {
writeError(w, http.StatusNotFound, "not_found", "Location not found")
return
}
s.logger.Error().Err(err).Str("path", path).Msg("failed to update location")
writeError(w, http.StatusInternalServerError, "update_failed", err.Error())
return
}
loc, _ := s.locations.GetByPath(ctx, path)
writeJSON(w, http.StatusOK, locationToResponse(loc))
}
// HandleDeleteLocation deletes a location by path. Rejects if inventory exists.
func (s *Server) HandleDeleteLocation(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
path := strings.Trim(chi.URLParam(r, "*"), "/")
if path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Location path is required")
return
}
if err := s.locations.Delete(ctx, path); err != nil {
if strings.Contains(err.Error(), "inventory record") {
writeError(w, http.StatusConflict, "has_inventory", err.Error())
return
}
if strings.Contains(err.Error(), "not found") {
writeError(w, http.StatusNotFound, "not_found", "Location not found")
return
}
s.logger.Error().Err(err).Str("path", path).Msg("failed to delete location")
writeError(w, http.StatusInternalServerError, "delete_failed", err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func locationsToResponse(locs []*db.Location) []LocationResponse {
result := make([]LocationResponse, len(locs))
for i, l := range locs {
result[i] = locationToResponse(l)
}
return result
}

View File

@@ -0,0 +1,323 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
func newLocationRouter(s *Server) http.Handler {
r := chi.NewRouter()
r.Get("/api/locations", s.HandleListLocations)
r.Post("/api/locations", s.HandleCreateLocation)
r.Get("/api/locations/*", s.HandleGetLocation)
r.Put("/api/locations/*", s.HandleUpdateLocation)
r.Delete("/api/locations/*", s.HandleDeleteLocation)
return r
}
func TestHandleListLocationsEmpty(t *testing.T) {
s := newTestServer(t)
router := newLocationRouter(s)
req := httptest.NewRequest("GET", "/api/locations", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
var locs []LocationResponse
if err := json.Unmarshal(w.Body.Bytes(), &locs); err != nil {
t.Fatalf("decoding response: %v", err)
}
if len(locs) != 0 {
t.Fatalf("expected 0 locations, got %d", len(locs))
}
}
func TestHandleCreateAndGetLocation(t *testing.T) {
s := newTestServer(t)
router := newLocationRouter(s)
// Create root location
body := `{"path": "lab", "name": "Lab", "location_type": "building"}`
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create status: got %d, want %d; body: %s", w.Code, http.StatusCreated, w.Body.String())
}
var created LocationResponse
if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
t.Fatalf("decoding create response: %v", err)
}
if created.Path != "lab" {
t.Errorf("path: got %q, want %q", created.Path, "lab")
}
if created.Name != "Lab" {
t.Errorf("name: got %q, want %q", created.Name, "Lab")
}
if created.Depth != 0 {
t.Errorf("depth: got %d, want 0", created.Depth)
}
if created.ID == "" {
t.Error("expected ID to be set")
}
// Get by path
req = httptest.NewRequest("GET", "/api/locations/lab", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("get status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
var got LocationResponse
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decoding get response: %v", err)
}
if got.ID != created.ID {
t.Errorf("ID mismatch: got %q, want %q", got.ID, created.ID)
}
}
func TestHandleCreateNestedLocation(t *testing.T) {
s := newTestServer(t)
router := newLocationRouter(s)
// Create root
body := `{"path": "warehouse", "name": "Warehouse", "location_type": "building"}`
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create root: got %d; body: %s", w.Code, w.Body.String())
}
// Create child
body = `{"path": "warehouse/shelf-a", "name": "Shelf A", "location_type": "shelf"}`
req = httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create child: got %d; body: %s", w.Code, w.Body.String())
}
var child LocationResponse
json.Unmarshal(w.Body.Bytes(), &child)
if child.Depth != 1 {
t.Errorf("child depth: got %d, want 1", child.Depth)
}
if child.ParentID == nil {
t.Error("expected parent_id to be set")
}
// Create grandchild
body = `{"path": "warehouse/shelf-a/bin-3", "name": "Bin 3", "location_type": "bin"}`
req = httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create grandchild: got %d; body: %s", w.Code, w.Body.String())
}
var gc LocationResponse
json.Unmarshal(w.Body.Bytes(), &gc)
if gc.Depth != 2 {
t.Errorf("grandchild depth: got %d, want 2", gc.Depth)
}
// Get nested path
req = httptest.NewRequest("GET", "/api/locations/warehouse/shelf-a/bin-3", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("get nested: got %d; body: %s", w.Code, w.Body.String())
}
}
func TestHandleCreateLocationMissingParent(t *testing.T) {
s := newTestServer(t)
router := newLocationRouter(s)
body := `{"path": "nonexistent/child", "name": "Child", "location_type": "shelf"}`
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestHandleUpdateLocation(t *testing.T) {
s := newTestServer(t)
router := newLocationRouter(s)
// Create
body := `{"path": "office", "name": "Office", "location_type": "room"}`
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create: got %d; body: %s", w.Code, w.Body.String())
}
// Update
body = `{"name": "Main Office", "location_type": "building", "metadata": {"floor": 2}}`
req = httptest.NewRequest("PUT", "/api/locations/office", strings.NewReader(body))
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("update: got %d; body: %s", w.Code, w.Body.String())
}
var updated LocationResponse
json.Unmarshal(w.Body.Bytes(), &updated)
if updated.Name != "Main Office" {
t.Errorf("name: got %q, want %q", updated.Name, "Main Office")
}
if updated.LocationType != "building" {
t.Errorf("type: got %q, want %q", updated.LocationType, "building")
}
}
func TestHandleDeleteLocation(t *testing.T) {
s := newTestServer(t)
router := newLocationRouter(s)
// Create
body := `{"path": "temp", "name": "Temp", "location_type": "area"}`
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create: got %d; body: %s", w.Code, w.Body.String())
}
// Delete
req = httptest.NewRequest("DELETE", "/api/locations/temp", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("delete: got %d, want %d; body: %s", w.Code, http.StatusNoContent, w.Body.String())
}
// Verify gone
req = httptest.NewRequest("GET", "/api/locations/temp", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("get after delete: got %d, want %d", w.Code, http.StatusNotFound)
}
}
func TestHandleDeleteLocationNotFound(t *testing.T) {
s := newTestServer(t)
router := newLocationRouter(s)
req := httptest.NewRequest("DELETE", "/api/locations/doesnotexist", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("delete missing: got %d, want %d; body: %s", w.Code, http.StatusNotFound, w.Body.String())
}
}
func TestHandleListLocationsTree(t *testing.T) {
s := newTestServer(t)
router := newLocationRouter(s)
// Create hierarchy
for _, loc := range []string{
`{"path": "site", "name": "Site", "location_type": "site"}`,
`{"path": "site/bldg", "name": "Building", "location_type": "building"}`,
`{"path": "site/bldg/room1", "name": "Room 1", "location_type": "room"}`,
`{"path": "other", "name": "Other", "location_type": "site"}`,
} {
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(loc))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create: got %d; body: %s", w.Code, w.Body.String())
}
}
// List tree under "site"
req := httptest.NewRequest("GET", "/api/locations?tree=site", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("tree: got %d; body: %s", w.Code, w.Body.String())
}
var locs []LocationResponse
json.Unmarshal(w.Body.Bytes(), &locs)
if len(locs) != 3 {
t.Fatalf("tree count: got %d, want 3 (site + bldg + room1)", len(locs))
}
// Full list should have 4
req = httptest.NewRequest("GET", "/api/locations", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
json.Unmarshal(w.Body.Bytes(), &locs)
if len(locs) != 4 {
t.Fatalf("full list: got %d, want 4", len(locs))
}
}
func TestHandleCreateLocationDuplicate(t *testing.T) {
s := newTestServer(t)
router := newLocationRouter(s)
body := `{"path": "dup", "name": "Dup", "location_type": "area"}`
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("first create: got %d; body: %s", w.Code, w.Body.String())
}
// Duplicate
req = httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("duplicate: got %d, want %d; body: %s", w.Code, http.StatusConflict, w.Body.String())
}
}
func TestHandleCreateLocationValidation(t *testing.T) {
s := newTestServer(t)
router := newLocationRouter(s)
tests := []struct {
name string
body string
}{
{"missing path", `{"name": "X", "location_type": "area"}`},
{"missing name", `{"path": "x", "location_type": "area"}`},
{"missing type", `{"path": "x", "name": "X"}`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(tc.body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("got %d, want 400; body: %s", w.Code, w.Body.String())
}
})
}
}

View File

@@ -0,0 +1,95 @@
package api
import (
"net/http"
"github.com/go-chi/chi/v5"
)
// MacroListItem is the JSON representation for GET /macros list entries.
type MacroListItem struct {
Filename string `json:"filename"`
Trigger string `json:"trigger"`
RevisionNumber int `json:"revision_number"`
}
// MacroResponse is the JSON representation for GET /macros/{filename}.
type MacroResponse struct {
Filename string `json:"filename"`
Trigger string `json:"trigger"`
Content string `json:"content"`
RevisionNumber int `json:"revision_number"`
}
// HandleGetMacros returns the list of registered macros for an item.
// GET /api/items/{partNumber}/macros
func (s *Server) HandleGetMacros(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
item, err := s.items.GetByPartNumber(ctx, partNumber)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get item")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
macros, err := s.macros.ListByItem(ctx, item.ID)
if err != nil {
s.logger.Error().Err(err).Msg("failed to list macros")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list macros")
return
}
resp := make([]MacroListItem, len(macros))
for i, m := range macros {
resp[i] = MacroListItem{
Filename: m.Filename,
Trigger: m.Trigger,
RevisionNumber: m.RevisionNumber,
}
}
writeJSON(w, http.StatusOK, resp)
}
// HandleGetMacro returns a single macro's source content.
// GET /api/items/{partNumber}/macros/{filename}
func (s *Server) HandleGetMacro(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
filename := chi.URLParam(r, "filename")
item, err := s.items.GetByPartNumber(ctx, partNumber)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get item")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
macro, err := s.macros.GetByFilename(ctx, item.ID, filename)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get macro")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get macro")
return
}
if macro == nil {
writeError(w, http.StatusNotFound, "not_found", "Macro not found")
return
}
writeJSON(w, http.StatusOK, MacroResponse{
Filename: macro.Filename,
Trigger: macro.Trigger,
Content: macro.Content,
RevisionNumber: macro.RevisionNumber,
})
}

View File

@@ -0,0 +1,472 @@
package api
import (
"context"
"encoding/json"
"io"
"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/kc"
)
// validTransitions defines allowed lifecycle state transitions for Phase 1.
var validTransitions = map[string][]string{
"draft": {"review"},
"review": {"draft", "released"},
"released": {"obsolete"},
"obsolete": {},
}
// MetadataResponse is the JSON representation returned by GET /metadata.
type MetadataResponse struct {
SchemaName *string `json:"schema_name"`
LifecycleState string `json:"lifecycle_state"`
Tags []string `json:"tags"`
Fields map[string]any `json:"fields"`
Manifest *ManifestInfo `json:"manifest,omitempty"`
UpdatedAt string `json:"updated_at"`
UpdatedBy *string `json:"updated_by,omitempty"`
}
// ManifestInfo is the manifest subset included in MetadataResponse.
type ManifestInfo struct {
UUID *string `json:"uuid,omitempty"`
SiloInstance *string `json:"silo_instance,omitempty"`
RevisionHash *string `json:"revision_hash,omitempty"`
KCVersion *string `json:"kc_version,omitempty"`
}
func metadataToResponse(m *db.ItemMetadata) MetadataResponse {
resp := MetadataResponse{
SchemaName: m.SchemaName,
LifecycleState: m.LifecycleState,
Tags: m.Tags,
Fields: m.Fields,
UpdatedAt: m.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
UpdatedBy: m.UpdatedBy,
}
if m.ManifestUUID != nil || m.SiloInstance != nil || m.RevisionHash != nil || m.KCVersion != nil {
resp.Manifest = &ManifestInfo{
UUID: m.ManifestUUID,
SiloInstance: m.SiloInstance,
RevisionHash: m.RevisionHash,
KCVersion: m.KCVersion,
}
}
return resp
}
// HandleGetMetadata returns indexed metadata for an item.
// GET /api/items/{partNumber}/metadata
func (s *Server) HandleGetMetadata(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
}
meta, err := s.metadata.Get(ctx, item.ID)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get metadata")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get metadata")
return
}
if meta == nil {
writeError(w, http.StatusNotFound, "not_found", "No metadata indexed for this item")
return
}
writeJSON(w, http.StatusOK, metadataToResponse(meta))
}
// HandleUpdateMetadata merges fields into the metadata JSONB.
// PUT /api/items/{partNumber}/metadata
func (s *Server) HandleUpdateMetadata(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 struct {
Fields map[string]any `json:"fields"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body")
return
}
if len(req.Fields) == 0 {
writeError(w, http.StatusBadRequest, "invalid_body", "Fields must not be empty")
return
}
username := ""
if user := auth.UserFromContext(ctx); user != nil {
username = user.Username
}
if err := s.metadata.UpdateFields(ctx, item.ID, req.Fields, username); err != nil {
s.logger.Error().Err(err).Msg("failed to update metadata fields")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update metadata")
return
}
meta, err := s.metadata.Get(ctx, item.ID)
if err != nil {
s.logger.Error().Err(err).Msg("failed to read back metadata")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read metadata")
return
}
s.broker.Publish("metadata.updated", mustMarshal(map[string]any{
"part_number": partNumber,
"changed_fields": fieldKeys(req.Fields),
"lifecycle_state": meta.LifecycleState,
"updated_by": username,
}))
writeJSON(w, http.StatusOK, metadataToResponse(meta))
}
// HandleUpdateLifecycle transitions the lifecycle state.
// PATCH /api/items/{partNumber}/metadata/lifecycle
func (s *Server) HandleUpdateLifecycle(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 struct {
State string `json:"state"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body")
return
}
if req.State == "" {
writeError(w, http.StatusBadRequest, "invalid_body", "State is required")
return
}
meta, err := s.metadata.Get(ctx, item.ID)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get metadata")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get metadata")
return
}
if meta == nil {
writeError(w, http.StatusNotFound, "not_found", "No metadata indexed for this item")
return
}
// Validate transition
allowed := validTransitions[meta.LifecycleState]
valid := false
for _, s := range allowed {
if s == req.State {
valid = true
break
}
}
if !valid {
writeError(w, http.StatusUnprocessableEntity, "invalid_transition",
"Cannot transition from '"+meta.LifecycleState+"' to '"+req.State+"'")
return
}
username := ""
if user := auth.UserFromContext(ctx); user != nil {
username = user.Username
}
fromState := meta.LifecycleState
if err := s.metadata.UpdateLifecycle(ctx, item.ID, req.State, username); err != nil {
s.logger.Error().Err(err).Msg("failed to update lifecycle")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update lifecycle")
return
}
s.broker.Publish("metadata.lifecycle", mustMarshal(map[string]any{
"part_number": partNumber,
"from_state": fromState,
"to_state": req.State,
"updated_by": username,
}))
writeJSON(w, http.StatusOK, map[string]string{"lifecycle_state": req.State})
}
// HandleUpdateTags adds/removes tags.
// PATCH /api/items/{partNumber}/metadata/tags
func (s *Server) HandleUpdateTags(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 struct {
Add []string `json:"add"`
Remove []string `json:"remove"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body")
return
}
if len(req.Add) == 0 && len(req.Remove) == 0 {
writeError(w, http.StatusBadRequest, "invalid_body", "Must provide 'add' or 'remove'")
return
}
meta, err := s.metadata.Get(ctx, item.ID)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get metadata")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get metadata")
return
}
if meta == nil {
writeError(w, http.StatusNotFound, "not_found", "No metadata indexed for this item")
return
}
// Compute new tag set: (existing + add) - remove
tagSet := make(map[string]struct{})
for _, t := range meta.Tags {
tagSet[t] = struct{}{}
}
for _, t := range req.Add {
tagSet[t] = struct{}{}
}
removeSet := make(map[string]struct{})
for _, t := range req.Remove {
removeSet[t] = struct{}{}
}
var newTags []string
for t := range tagSet {
if _, removed := removeSet[t]; !removed {
newTags = append(newTags, t)
}
}
if newTags == nil {
newTags = []string{}
}
username := ""
if user := auth.UserFromContext(ctx); user != nil {
username = user.Username
}
if err := s.metadata.SetTags(ctx, item.ID, newTags, username); err != nil {
s.logger.Error().Err(err).Msg("failed to update tags")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update tags")
return
}
s.broker.Publish("metadata.tags", mustMarshal(map[string]any{
"part_number": partNumber,
"added": req.Add,
"removed": req.Remove,
}))
writeJSON(w, http.StatusOK, map[string]any{"tags": newTags})
}
// extractKCMetadata attempts to extract and index silo/ metadata from an
// uploaded .kc file. Failures are logged but non-fatal for Phase 1.
func (s *Server) extractKCMetadata(ctx context.Context, item *db.Item, fileKey string, rev *db.Revision) {
if s.storage == nil {
return
}
reader, err := s.storage.Get(ctx, fileKey)
if err != nil {
s.logger.Warn().Err(err).Str("file_key", fileKey).Msg("kc: failed to read back file for extraction")
return
}
defer reader.Close()
data, err := io.ReadAll(reader)
if err != nil {
s.logger.Warn().Err(err).Msg("kc: failed to read file bytes")
return
}
result, err := kc.Extract(data)
if err != nil {
s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: extraction failed")
return
}
if result == nil {
return // plain .fcstd, no silo/ directory
}
// Validate manifest UUID matches item
if result.Manifest != nil && result.Manifest.UUID != "" && result.Manifest.UUID != item.ID {
s.logger.Warn().
Str("manifest_uuid", result.Manifest.UUID).
Str("item_id", item.ID).
Msg("kc: manifest UUID does not match item, skipping indexing")
return
}
// Check for no-op (revision_hash unchanged)
if result.Manifest != nil && result.Manifest.RevisionHash != "" {
existing, _ := s.metadata.Get(ctx, item.ID)
if existing != nil && existing.RevisionHash != nil && *existing.RevisionHash == result.Manifest.RevisionHash {
s.logger.Debug().Str("part_number", item.PartNumber).Msg("kc: revision_hash unchanged, skipping")
return
}
}
username := ""
if rev.CreatedBy != nil {
username = *rev.CreatedBy
}
meta := &db.ItemMetadata{
ItemID: item.ID,
LifecycleState: "draft",
Fields: make(map[string]any),
Tags: []string{},
UpdatedBy: strPtr(username),
}
if result.Manifest != nil {
meta.KCVersion = strPtr(result.Manifest.KCVersion)
meta.ManifestUUID = strPtr(result.Manifest.UUID)
meta.SiloInstance = strPtr(result.Manifest.SiloInstance)
meta.RevisionHash = strPtr(result.Manifest.RevisionHash)
}
if result.Metadata != nil {
meta.SchemaName = strPtr(result.Metadata.SchemaName)
if result.Metadata.Tags != nil {
meta.Tags = result.Metadata.Tags
}
if result.Metadata.LifecycleState != "" {
meta.LifecycleState = result.Metadata.LifecycleState
}
if result.Metadata.Fields != nil {
meta.Fields = result.Metadata.Fields
}
}
if err := s.metadata.Upsert(ctx, meta); err != nil {
s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: failed to upsert metadata")
return
}
s.broker.Publish("metadata.updated", mustMarshal(map[string]any{
"part_number": item.PartNumber,
"lifecycle_state": meta.LifecycleState,
"updated_by": username,
}))
// Index dependencies from silo/dependencies.json.
if result.Dependencies != nil {
dbDeps := make([]*db.ItemDependency, len(result.Dependencies))
for i, d := range result.Dependencies {
pn := d.PartNumber
rev := d.Revision
qty := d.Quantity
label := d.Label
rel := d.Relationship
if rel == "" {
rel = "component"
}
dbDeps[i] = &db.ItemDependency{
ParentItemID: item.ID,
ChildUUID: d.UUID,
ChildPartNumber: &pn,
ChildRevision: &rev,
Quantity: &qty,
Label: &label,
Relationship: rel,
}
}
if err := s.deps.ReplaceForRevision(ctx, item.ID, rev.RevisionNumber, dbDeps); err != nil {
s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: failed to index dependencies")
} else {
s.broker.Publish("dependencies.changed", mustMarshal(map[string]any{
"part_number": item.PartNumber,
"count": len(dbDeps),
}))
}
}
// Index macros from silo/macros/*.
if len(result.Macros) > 0 {
dbMacros := make([]*db.ItemMacro, len(result.Macros))
for i, m := range result.Macros {
dbMacros[i] = &db.ItemMacro{
ItemID: item.ID,
Filename: m.Filename,
Trigger: "manual",
Content: m.Content,
}
}
if err := s.macros.ReplaceForItem(ctx, item.ID, rev.RevisionNumber, dbMacros); err != nil {
s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: failed to index macros")
} else {
s.broker.Publish("macros.changed", mustMarshal(map[string]any{
"part_number": item.PartNumber,
"count": len(dbMacros),
}))
}
}
s.logger.Info().Str("part_number", item.PartNumber).Msg("kc: metadata indexed successfully")
}
// strPtr returns a pointer to s, or nil if s is empty.
func strPtr(s string) *string {
if s == "" {
return nil
}
return &s
}
// fieldKeys returns the keys from a map.
func fieldKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}

View File

@@ -0,0 +1,182 @@
package api
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"github.com/kindredsystems/silo/internal/db"
"github.com/kindredsystems/silo/internal/kc"
)
// packKCFile gathers DB state and repacks silo/ entries in a .kc file.
func (s *Server) packKCFile(ctx context.Context, data []byte, item *db.Item, rev *db.Revision, meta *db.ItemMetadata) ([]byte, error) {
manifest := &kc.Manifest{
UUID: item.ID,
KCVersion: derefStr(meta.KCVersion, "1.0"),
RevisionHash: derefStr(meta.RevisionHash, ""),
SiloInstance: derefStr(meta.SiloInstance, ""),
}
metadata := &kc.Metadata{
SchemaName: derefStr(meta.SchemaName, ""),
Tags: meta.Tags,
LifecycleState: meta.LifecycleState,
Fields: meta.Fields,
}
// Build history from last 20 revisions.
revisions, err := s.items.GetRevisions(ctx, item.ID)
if err != nil {
return nil, fmt.Errorf("getting revisions: %w", err)
}
limit := 20
if len(revisions) < limit {
limit = len(revisions)
}
history := make([]kc.HistoryEntry, limit)
for i, r := range revisions[:limit] {
labels := r.Labels
if labels == nil {
labels = []string{}
}
history[i] = kc.HistoryEntry{
RevisionNumber: r.RevisionNumber,
CreatedAt: r.CreatedAt.UTC().Format(time.RFC3339),
CreatedBy: r.CreatedBy,
Comment: r.Comment,
Status: r.Status,
Labels: labels,
}
}
// Build dependencies from item_dependencies table.
var deps []kc.Dependency
dbDeps, err := s.deps.ListByItem(ctx, item.ID)
if err != nil {
s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: failed to query dependencies for packing")
} else {
deps = make([]kc.Dependency, len(dbDeps))
for i, d := range dbDeps {
deps[i] = kc.Dependency{
UUID: d.ChildUUID,
PartNumber: derefStr(d.ChildPartNumber, ""),
Revision: derefInt(d.ChildRevision, 0),
Quantity: derefFloat(d.Quantity, 0),
Label: derefStr(d.Label, ""),
Relationship: d.Relationship,
}
}
}
if deps == nil {
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)
}
// computeETag generates a quoted ETag from the revision number and metadata freshness.
func computeETag(rev *db.Revision, meta *db.ItemMetadata) string {
var ts int64
if meta != nil {
ts = meta.UpdatedAt.UnixNano()
} else {
ts = rev.CreatedAt.UnixNano()
}
raw := fmt.Sprintf("%d:%d", rev.RevisionNumber, ts)
h := sha256.Sum256([]byte(raw))
return `"` + hex.EncodeToString(h[:8]) + `"`
}
// canSkipRepack returns true if the stored blob already has up-to-date silo/ data.
func canSkipRepack(rev *db.Revision, meta *db.ItemMetadata) bool {
if meta == nil {
return true // no metadata row = plain .fcstd
}
if meta.RevisionHash != nil && rev.FileChecksum != nil &&
*meta.RevisionHash == *rev.FileChecksum &&
meta.UpdatedAt.Before(rev.CreatedAt) {
return true
}
return false
}
// derefStr returns the value of a *string pointer, or fallback if nil.
func derefStr(p *string, fallback string) string {
if p != nil {
return *p
}
return fallback
}
// derefInt returns the value of a *int pointer, or fallback if nil.
func derefInt(p *int, fallback int) int {
if p != nil {
return *p
}
return fallback
}
// derefFloat returns the value of a *float64 pointer, or fallback if nil.
func derefFloat(p *float64, fallback float64) float64 {
if p != nil {
return *p
}
return fallback
}

View File

@@ -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) {
@@ -117,6 +134,26 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
})
})
// Locations (read: viewer, write: editor)
r.Route("/locations", func(r chi.Router) {
r.Get("/", server.HandleListLocations)
r.Group(func(r chi.Router) {
r.Use(server.RequireWritable)
r.Use(server.RequireRole(auth.RoleEditor))
r.Post("/", server.HandleCreateLocation)
})
// Wildcard routes for path-based lookup (e.g., /api/locations/lab/shelf-a/bin-3)
r.Get("/*", server.HandleGetLocation)
r.Group(func(r chi.Router) {
r.Use(server.RequireWritable)
r.Use(server.RequireRole(auth.RoleEditor))
r.Put("/*", server.HandleUpdateLocation)
r.Delete("/*", server.HandleDeleteLocation)
})
})
// Items (read: viewer, write: editor)
r.Route("/items", func(r chi.Router) {
r.Get("/", server.HandleListItems)
@@ -142,6 +179,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
r.Get("/revisions/compare", server.HandleCompareRevisions)
r.Get("/revisions/{revision}", server.HandleGetRevision)
r.Get("/files", server.HandleListItemFiles)
r.Get("/files/{fileId}/download", server.HandleDownloadItemFile)
r.Get("/file", server.HandleDownloadLatestFile)
r.Get("/file/{revision}", server.HandleDownloadFile)
r.Get("/bom", server.HandleGetBOM)
@@ -151,6 +189,13 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
r.Get("/bom/where-used", server.HandleGetWhereUsed)
r.Get("/bom/export.csv", server.HandleExportBOMCSV)
r.Get("/bom/export.ods", server.HandleExportBOMODS)
r.Get("/metadata", server.HandleGetMetadata)
r.Get("/dependencies", server.HandleGetDependencies)
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) {
@@ -167,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))
@@ -179,13 +237,20 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
r.Post("/revisions/{revision}/rollback", server.HandleRollbackRevision)
r.Post("/file", server.HandleUploadFile)
r.Post("/files", server.HandleAssociateItemFile)
r.Post("/files/upload", server.HandleUploadItemFile)
r.Delete("/files/{fileId}", server.HandleDeleteItemFile)
r.Put("/thumbnail", server.HandleSetItemThumbnail)
r.Post("/thumbnail/upload", server.HandleUploadItemThumbnail)
r.Post("/bom", server.HandleAddBOMEntry)
r.Post("/bom/import", server.HandleImportBOMCSV)
r.Post("/bom/merge", server.HandleMergeBOM)
r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry)
r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry)
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)
})
})
})
@@ -213,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)

View File

@@ -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,

View File

@@ -26,13 +26,13 @@ type ServerState struct {
mu sync.RWMutex
readOnly bool
storageOK bool
storage *storage.Storage
storage storage.FileStore
broker *Broker
done chan struct{}
}
// NewServerState creates a new server state tracker.
func NewServerState(logger zerolog.Logger, store *storage.Storage, broker *Broker) *ServerState {
func NewServerState(logger zerolog.Logger, store storage.FileStore, broker *Broker) *ServerState {
return &ServerState{
logger: logger.With().Str("component", "server-state").Logger(),
storageOK: store != nil, // assume healthy if configured
@@ -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 {

View File

@@ -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)

View 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)
}

View File

@@ -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 {

View File

@@ -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
)
}

View 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")
}
}

View File

@@ -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 {

View 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)
}

View File

@@ -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,14 +113,15 @@ type DatabaseConfig struct {
MaxConnections int `yaml:"max_connections"`
}
// StorageConfig holds MinIO connection settings.
// StorageConfig holds file storage settings.
type StorageConfig struct {
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"`
}
// FilesystemConfig holds local filesystem storage settings.
type FilesystemConfig struct {
RootDir string `yaml:"root_dir"`
}
// SchemasConfig holds schema loading settings.
@@ -139,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"`
@@ -176,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"
}
@@ -197,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 != "" {
@@ -211,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

View 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
}

View 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
}

View File

@@ -0,0 +1,127 @@
package db
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// ItemDependency represents a row in the item_dependencies table.
type ItemDependency struct {
ID string
ParentItemID string
ChildUUID string
ChildPartNumber *string
ChildRevision *int
Quantity *float64
Label *string
Relationship string
RevisionNumber int
CreatedAt time.Time
}
// ResolvedDependency extends ItemDependency with resolution info from a LEFT JOIN.
type ResolvedDependency struct {
ItemDependency
ResolvedPartNumber *string
ResolvedRevision *int
Resolved bool
}
// ItemDependencyRepository provides item_dependencies database operations.
type ItemDependencyRepository struct {
db *DB
}
// NewItemDependencyRepository creates a new item dependency repository.
func NewItemDependencyRepository(db *DB) *ItemDependencyRepository {
return &ItemDependencyRepository{db: db}
}
// ReplaceForRevision atomically replaces all dependencies for an item's revision.
// Deletes existing rows for the parent item and inserts the new set.
func (r *ItemDependencyRepository) ReplaceForRevision(ctx context.Context, parentItemID string, revisionNumber int, deps []*ItemDependency) error {
return r.db.Tx(ctx, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `DELETE FROM item_dependencies WHERE parent_item_id = $1`, parentItemID)
if err != nil {
return fmt.Errorf("deleting old dependencies: %w", err)
}
for _, d := range deps {
_, err := tx.Exec(ctx, `
INSERT INTO item_dependencies
(parent_item_id, child_uuid, child_part_number, child_revision,
quantity, label, relationship, revision_number)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, parentItemID, d.ChildUUID, d.ChildPartNumber, d.ChildRevision,
d.Quantity, d.Label, d.Relationship, revisionNumber)
if err != nil {
return fmt.Errorf("inserting dependency: %w", err)
}
}
return nil
})
}
// ListByItem returns all dependencies for an item.
func (r *ItemDependencyRepository) ListByItem(ctx context.Context, parentItemID string) ([]*ItemDependency, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT id, parent_item_id, child_uuid, child_part_number, child_revision,
quantity, label, relationship, revision_number, created_at
FROM item_dependencies
WHERE parent_item_id = $1
ORDER BY label NULLS LAST
`, parentItemID)
if err != nil {
return nil, fmt.Errorf("listing dependencies: %w", err)
}
defer rows.Close()
var deps []*ItemDependency
for rows.Next() {
d := &ItemDependency{}
if err := rows.Scan(
&d.ID, &d.ParentItemID, &d.ChildUUID, &d.ChildPartNumber, &d.ChildRevision,
&d.Quantity, &d.Label, &d.Relationship, &d.RevisionNumber, &d.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scanning dependency: %w", err)
}
deps = append(deps, d)
}
return deps, nil
}
// Resolve returns dependencies with child UUIDs resolved against the items table.
// Unresolvable UUIDs (external or deleted items) have Resolved=false.
func (r *ItemDependencyRepository) Resolve(ctx context.Context, parentItemID string) ([]*ResolvedDependency, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT d.id, d.parent_item_id, d.child_uuid, d.child_part_number, d.child_revision,
d.quantity, d.label, d.relationship, d.revision_number, d.created_at,
i.part_number, i.current_revision
FROM item_dependencies d
LEFT JOIN items i ON i.id = d.child_uuid AND i.archived_at IS NULL
WHERE d.parent_item_id = $1
ORDER BY d.label NULLS LAST
`, parentItemID)
if err != nil {
return nil, fmt.Errorf("resolving dependencies: %w", err)
}
defer rows.Close()
var deps []*ResolvedDependency
for rows.Next() {
d := &ResolvedDependency{}
if err := rows.Scan(
&d.ID, &d.ParentItemID, &d.ChildUUID, &d.ChildPartNumber, &d.ChildRevision,
&d.Quantity, &d.Label, &d.Relationship, &d.RevisionNumber, &d.CreatedAt,
&d.ResolvedPartNumber, &d.ResolvedRevision,
); err != nil {
return nil, fmt.Errorf("scanning resolved dependency: %w", err)
}
d.Resolved = d.ResolvedPartNumber != nil
deps = append(deps, d)
}
return deps, nil
}

View File

@@ -8,13 +8,14 @@ import (
// ItemFile represents a file attachment on an item.
type ItemFile struct {
ID string
ItemID string
Filename string
ContentType string
Size int64
ObjectKey string
CreatedAt time.Time
ID string
ItemID string
Filename string
ContentType string
Size int64
ObjectKey string
StorageBackend string
CreatedAt time.Time
}
// ItemFileRepository provides item_files database operations.
@@ -29,11 +30,14 @@ 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 = "filesystem"
}
err := r.db.pool.QueryRow(ctx,
`INSERT INTO item_files (item_id, filename, content_type, size, object_key)
VALUES ($1, $2, $3, $4, $5)
`INSERT INTO item_files (item_id, filename, content_type, size, object_key, storage_backend)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at`,
f.ItemID, f.Filename, f.ContentType, f.Size, f.ObjectKey,
f.ItemID, f.Filename, f.ContentType, f.Size, f.ObjectKey, f.StorageBackend,
).Scan(&f.ID, &f.CreatedAt)
if err != nil {
return fmt.Errorf("creating item file: %w", err)
@@ -44,7 +48,8 @@ func (r *ItemFileRepository) Create(ctx context.Context, f *ItemFile) error {
// ListByItem returns all file attachments for an item.
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, created_at
`SELECT id, item_id, filename, content_type, size, object_key,
COALESCE(storage_backend, 'filesystem'), created_at
FROM item_files WHERE item_id = $1 ORDER BY created_at`,
itemID,
)
@@ -56,7 +61,7 @@ func (r *ItemFileRepository) ListByItem(ctx context.Context, itemID string) ([]*
var files []*ItemFile
for rows.Next() {
f := &ItemFile{}
if err := rows.Scan(&f.ID, &f.ItemID, &f.Filename, &f.ContentType, &f.Size, &f.ObjectKey, &f.CreatedAt); err != nil {
if err := rows.Scan(&f.ID, &f.ItemID, &f.Filename, &f.ContentType, &f.Size, &f.ObjectKey, &f.StorageBackend, &f.CreatedAt); err != nil {
return nil, fmt.Errorf("scanning item file: %w", err)
}
files = append(files, f)
@@ -68,10 +73,11 @@ func (r *ItemFileRepository) ListByItem(ctx context.Context, itemID string) ([]*
func (r *ItemFileRepository) Get(ctx context.Context, id string) (*ItemFile, error) {
f := &ItemFile{}
err := r.db.pool.QueryRow(ctx,
`SELECT id, item_id, filename, content_type, size, object_key, created_at
`SELECT id, item_id, filename, content_type, size, object_key,
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.CreatedAt)
).Scan(&f.ID, &f.ItemID, &f.Filename, &f.ContentType, &f.Size, &f.ObjectKey, &f.StorageBackend, &f.CreatedAt)
if err != nil {
return nil, fmt.Errorf("getting item file: %w", err)
}

View File

@@ -0,0 +1,93 @@
package db
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// ItemMacro represents a row in the item_macros table.
type ItemMacro struct {
ID string
ItemID string
Filename string
Trigger string
Content string
RevisionNumber int
CreatedAt time.Time
}
// ItemMacroRepository provides item_macros database operations.
type ItemMacroRepository struct {
db *DB
}
// NewItemMacroRepository creates a new item macro repository.
func NewItemMacroRepository(db *DB) *ItemMacroRepository {
return &ItemMacroRepository{db: db}
}
// ReplaceForItem atomically replaces all macros for an item.
// Deletes existing rows and inserts the new set.
func (r *ItemMacroRepository) ReplaceForItem(ctx context.Context, itemID string, revisionNumber int, macros []*ItemMacro) error {
return r.db.Tx(ctx, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `DELETE FROM item_macros WHERE item_id = $1`, itemID)
if err != nil {
return fmt.Errorf("deleting old macros: %w", err)
}
for _, m := range macros {
_, err := tx.Exec(ctx, `
INSERT INTO item_macros (item_id, filename, trigger, content, revision_number)
VALUES ($1, $2, $3, $4, $5)
`, itemID, m.Filename, m.Trigger, m.Content, revisionNumber)
if err != nil {
return fmt.Errorf("inserting macro %s: %w", m.Filename, err)
}
}
return nil
})
}
// ListByItem returns all macros for an item (without content), ordered by filename.
func (r *ItemMacroRepository) ListByItem(ctx context.Context, itemID string) ([]*ItemMacro, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT id, item_id, filename, trigger, revision_number, created_at
FROM item_macros
WHERE item_id = $1
ORDER BY filename
`, itemID)
if err != nil {
return nil, fmt.Errorf("listing macros: %w", err)
}
defer rows.Close()
var macros []*ItemMacro
for rows.Next() {
m := &ItemMacro{}
if err := rows.Scan(&m.ID, &m.ItemID, &m.Filename, &m.Trigger, &m.RevisionNumber, &m.CreatedAt); err != nil {
return nil, fmt.Errorf("scanning macro: %w", err)
}
macros = append(macros, m)
}
return macros, nil
}
// GetByFilename returns a single macro by item ID and filename, including content.
func (r *ItemMacroRepository) GetByFilename(ctx context.Context, itemID string, filename string) (*ItemMacro, error) {
m := &ItemMacro{}
err := r.db.pool.QueryRow(ctx, `
SELECT id, item_id, filename, trigger, content, revision_number, created_at
FROM item_macros
WHERE item_id = $1 AND filename = $2
`, itemID, filename).Scan(&m.ID, &m.ItemID, &m.Filename, &m.Trigger, &m.Content, &m.RevisionNumber, &m.CreatedAt)
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("getting macro: %w", err)
}
return m, nil
}

View File

@@ -0,0 +1,161 @@
package db
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// ItemMetadata represents a row in the item_metadata table.
type ItemMetadata struct {
ItemID string
SchemaName *string
Tags []string
LifecycleState string
Fields map[string]any
KCVersion *string
ManifestUUID *string
SiloInstance *string
RevisionHash *string
UpdatedAt time.Time
UpdatedBy *string
}
// ItemMetadataRepository provides item_metadata database operations.
type ItemMetadataRepository struct {
db *DB
}
// NewItemMetadataRepository creates a new item metadata repository.
func NewItemMetadataRepository(db *DB) *ItemMetadataRepository {
return &ItemMetadataRepository{db: db}
}
// Get returns metadata for an item, or nil if none exists.
func (r *ItemMetadataRepository) Get(ctx context.Context, itemID string) (*ItemMetadata, error) {
m := &ItemMetadata{}
var fieldsJSON []byte
err := r.db.pool.QueryRow(ctx, `
SELECT item_id, schema_name, tags, lifecycle_state, fields,
kc_version, manifest_uuid, silo_instance, revision_hash,
updated_at, updated_by
FROM item_metadata
WHERE item_id = $1
`, itemID).Scan(
&m.ItemID, &m.SchemaName, &m.Tags, &m.LifecycleState, &fieldsJSON,
&m.KCVersion, &m.ManifestUUID, &m.SiloInstance, &m.RevisionHash,
&m.UpdatedAt, &m.UpdatedBy,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting item metadata: %w", err)
}
if fieldsJSON != nil {
if err := json.Unmarshal(fieldsJSON, &m.Fields); err != nil {
return nil, fmt.Errorf("unmarshaling fields: %w", err)
}
}
if m.Fields == nil {
m.Fields = make(map[string]any)
}
if m.Tags == nil {
m.Tags = []string{}
}
return m, nil
}
// Upsert inserts or updates the metadata row for an item.
// Used by the commit extraction pipeline.
func (r *ItemMetadataRepository) Upsert(ctx context.Context, m *ItemMetadata) error {
fieldsJSON, err := json.Marshal(m.Fields)
if err != nil {
return fmt.Errorf("marshaling fields: %w", err)
}
_, err = r.db.pool.Exec(ctx, `
INSERT INTO item_metadata
(item_id, schema_name, tags, lifecycle_state, fields,
kc_version, manifest_uuid, silo_instance, revision_hash,
updated_at, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now(), $10)
ON CONFLICT (item_id) DO UPDATE SET
schema_name = EXCLUDED.schema_name,
tags = EXCLUDED.tags,
lifecycle_state = EXCLUDED.lifecycle_state,
fields = EXCLUDED.fields,
kc_version = EXCLUDED.kc_version,
manifest_uuid = EXCLUDED.manifest_uuid,
silo_instance = EXCLUDED.silo_instance,
revision_hash = EXCLUDED.revision_hash,
updated_at = now(),
updated_by = EXCLUDED.updated_by
`, m.ItemID, m.SchemaName, m.Tags, m.LifecycleState, fieldsJSON,
m.KCVersion, m.ManifestUUID, m.SiloInstance, m.RevisionHash,
m.UpdatedBy)
if err != nil {
return fmt.Errorf("upserting item metadata: %w", err)
}
return nil
}
// UpdateFields merges the given fields into the existing JSONB fields column.
func (r *ItemMetadataRepository) UpdateFields(ctx context.Context, itemID string, fields map[string]any, updatedBy string) error {
fieldsJSON, err := json.Marshal(fields)
if err != nil {
return fmt.Errorf("marshaling fields: %w", err)
}
tag, err := r.db.pool.Exec(ctx, `
UPDATE item_metadata
SET fields = fields || $2::jsonb,
updated_at = now(),
updated_by = $3
WHERE item_id = $1
`, itemID, fieldsJSON, updatedBy)
if err != nil {
return fmt.Errorf("updating metadata fields: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("item metadata not found")
}
return nil
}
// UpdateLifecycle sets the lifecycle_state column.
func (r *ItemMetadataRepository) UpdateLifecycle(ctx context.Context, itemID, state, updatedBy string) error {
tag, err := r.db.pool.Exec(ctx, `
UPDATE item_metadata
SET lifecycle_state = $2,
updated_at = now(),
updated_by = $3
WHERE item_id = $1
`, itemID, state, updatedBy)
if err != nil {
return fmt.Errorf("updating lifecycle state: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("item metadata not found")
}
return nil
}
// SetTags replaces the tags array.
func (r *ItemMetadataRepository) SetTags(ctx context.Context, itemID string, tags []string, updatedBy string) error {
tag, err := r.db.pool.Exec(ctx, `
UPDATE item_metadata
SET tags = $2,
updated_at = now(),
updated_by = $3
WHERE item_id = $1
`, itemID, tags, updatedBy)
if err != nil {
return fmt.Errorf("updating tags: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("item metadata not found")
}
return nil
}

View File

@@ -26,25 +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
FileKey *string
FileVersion *string
FileChecksum *string
FileSize *int64
ThumbnailKey *string
CreatedAt time.Time
CreatedBy *string
Comment *string
Status string // draft, review, released, obsolete
Labels []string // arbitrary tags
ID string
ItemID string
RevisionNumber int
Properties map[string]any
FileKey *string
FileVersion *string
FileChecksum *string
FileSize *int64
FileStorageBackend string
ThumbnailKey *string
CreatedAt time.Time
CreatedBy *string
Comment *string
Status string // draft, review, released, obsolete
Labels []string // arbitrary tags
}
// RevisionStatus constants
@@ -306,16 +307,20 @@ func (r *ItemRepository) CreateRevision(ctx context.Context, rev *Revision) erro
return fmt.Errorf("marshaling properties: %w", err)
}
if rev.FileStorageBackend == "" {
rev.FileStorageBackend = "filesystem"
}
err = r.db.pool.QueryRow(ctx, `
INSERT INTO revisions (
item_id, revision_number, properties, file_key, file_version,
file_checksum, file_size, thumbnail_key, created_by, comment
file_checksum, file_size, file_storage_backend, thumbnail_key, created_by, comment
)
SELECT $1, current_revision + 1, $2, $3, $4, $5, $6, $7, $8, $9
SELECT $1, current_revision + 1, $2, $3, $4, $5, $6, $7, $8, $9, $10
FROM items WHERE id = $1
RETURNING id, revision_number, created_at
`, rev.ItemID, propsJSON, rev.FileKey, rev.FileVersion,
rev.FileChecksum, rev.FileSize, rev.ThumbnailKey, rev.CreatedBy, rev.Comment,
rev.FileChecksum, rev.FileSize, rev.FileStorageBackend, rev.ThumbnailKey, rev.CreatedBy, rev.Comment,
).Scan(&rev.ID, &rev.RevisionNumber, &rev.CreatedAt)
if err != nil {
return fmt.Errorf("inserting revision: %w", err)
@@ -342,7 +347,8 @@ 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, thumbnail_key, created_at, created_by, comment,
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
WHERE item_id = $1
@@ -369,7 +375,8 @@ func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Re
if hasStatusColumn {
err = rows.Scan(
&rev.ID, &rev.ItemID, &rev.RevisionNumber, &propsJSON, &rev.FileKey, &rev.FileVersion,
&rev.FileChecksum, &rev.FileSize, &rev.ThumbnailKey, &rev.CreatedAt, &rev.CreatedBy, &rev.Comment,
&rev.FileChecksum, &rev.FileSize, &rev.FileStorageBackend,
&rev.ThumbnailKey, &rev.CreatedAt, &rev.CreatedBy, &rev.Comment,
&rev.Status, &rev.Labels,
)
} else {
@@ -379,6 +386,7 @@ func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Re
)
rev.Status = "draft"
rev.Labels = []string{}
rev.FileStorageBackend = "filesystem"
}
if err != nil {
return nil, fmt.Errorf("scanning revision: %w", err)
@@ -412,13 +420,15 @@ 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, thumbnail_key, created_at, created_by, comment,
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
WHERE item_id = $1 AND revision_number = $2
`, itemID, revisionNumber).Scan(
&rev.ID, &rev.ItemID, &rev.RevisionNumber, &propsJSON, &rev.FileKey, &rev.FileVersion,
&rev.FileChecksum, &rev.FileSize, &rev.ThumbnailKey, &rev.CreatedAt, &rev.CreatedBy, &rev.Comment,
&rev.FileChecksum, &rev.FileSize, &rev.FileStorageBackend,
&rev.ThumbnailKey, &rev.CreatedAt, &rev.CreatedBy, &rev.Comment,
&rev.Status, &rev.Labels,
)
} else {
@@ -433,6 +443,7 @@ func (r *ItemRepository) GetRevision(ctx context.Context, itemID string, revisio
)
rev.Status = "draft"
rev.Labels = []string{}
rev.FileStorageBackend = "filesystem"
}
if err == pgx.ErrNoRows {
@@ -606,15 +617,16 @@ func (r *ItemRepository) CreateRevisionFromExisting(ctx context.Context, itemID
// Create new revision with copied properties (and optionally file reference)
newRev := &Revision{
ItemID: itemID,
Properties: source.Properties,
FileKey: source.FileKey,
FileVersion: source.FileVersion,
FileChecksum: source.FileChecksum,
FileSize: source.FileSize,
ThumbnailKey: source.ThumbnailKey,
CreatedBy: createdBy,
Comment: &comment,
ItemID: itemID,
Properties: source.Properties,
FileKey: source.FileKey,
FileVersion: source.FileVersion,
FileChecksum: source.FileChecksum,
FileSize: source.FileSize,
FileStorageBackend: source.FileStorageBackend,
ThumbnailKey: source.ThumbnailKey,
CreatedBy: createdBy,
Comment: &comment,
}
// Insert the new revision
@@ -626,13 +638,13 @@ func (r *ItemRepository) CreateRevisionFromExisting(ctx context.Context, itemID
err = r.db.pool.QueryRow(ctx, `
INSERT INTO revisions (
item_id, revision_number, properties, file_key, file_version,
file_checksum, file_size, thumbnail_key, created_by, comment, status
file_checksum, file_size, file_storage_backend, thumbnail_key, created_by, comment, status
)
SELECT $1, current_revision + 1, $2, $3, $4, $5, $6, $7, $8, $9, 'draft'
SELECT $1, current_revision + 1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'draft'
FROM items WHERE id = $1
RETURNING id, revision_number, created_at
`, newRev.ItemID, propsJSON, newRev.FileKey, newRev.FileVersion,
newRev.FileChecksum, newRev.FileSize, newRev.ThumbnailKey, newRev.CreatedBy, newRev.Comment,
newRev.FileChecksum, newRev.FileSize, newRev.FileStorageBackend, newRev.ThumbnailKey, newRev.CreatedBy, newRev.Comment,
).Scan(&newRev.ID, &newRev.RevisionNumber, &newRev.CreatedAt)
if err != nil {
return nil, fmt.Errorf("inserting revision: %w", err)

View File

@@ -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) {

230
internal/db/locations.go Normal file
View File

@@ -0,0 +1,230 @@
package db
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// Location represents a location in the hierarchy.
type Location struct {
ID string
Path string
Name string
ParentID *string
LocationType string
Depth int
Metadata map[string]any
CreatedAt time.Time
}
// LocationRepository provides location database operations.
type LocationRepository struct {
db *DB
}
// NewLocationRepository creates a new location repository.
func NewLocationRepository(db *DB) *LocationRepository {
return &LocationRepository{db: db}
}
// List returns all locations ordered by path.
func (r *LocationRepository) List(ctx context.Context) ([]*Location, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT id, path, name, parent_id, location_type, depth, metadata, created_at
FROM locations
ORDER BY path
`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanLocations(rows)
}
// GetByPath returns a location by its path.
func (r *LocationRepository) GetByPath(ctx context.Context, path string) (*Location, error) {
loc := &Location{}
var meta []byte
err := r.db.pool.QueryRow(ctx, `
SELECT id, path, name, parent_id, location_type, depth, metadata, created_at
FROM locations
WHERE path = $1
`, path).Scan(&loc.ID, &loc.Path, &loc.Name, &loc.ParentID, &loc.LocationType, &loc.Depth, &meta, &loc.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if meta != nil {
json.Unmarshal(meta, &loc.Metadata)
}
return loc, nil
}
// GetByID returns a location by its ID.
func (r *LocationRepository) GetByID(ctx context.Context, id string) (*Location, error) {
loc := &Location{}
var meta []byte
err := r.db.pool.QueryRow(ctx, `
SELECT id, path, name, parent_id, location_type, depth, metadata, created_at
FROM locations
WHERE id = $1
`, id).Scan(&loc.ID, &loc.Path, &loc.Name, &loc.ParentID, &loc.LocationType, &loc.Depth, &meta, &loc.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if meta != nil {
json.Unmarshal(meta, &loc.Metadata)
}
return loc, nil
}
// GetChildren returns direct children of a location.
func (r *LocationRepository) GetChildren(ctx context.Context, parentID string) ([]*Location, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT id, path, name, parent_id, location_type, depth, metadata, created_at
FROM locations
WHERE parent_id = $1
ORDER BY path
`, parentID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanLocations(rows)
}
// GetTree returns a location and all its descendants (by path prefix).
func (r *LocationRepository) GetTree(ctx context.Context, rootPath string) ([]*Location, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT id, path, name, parent_id, location_type, depth, metadata, created_at
FROM locations
WHERE path = $1 OR path LIKE $2
ORDER BY path
`, rootPath, rootPath+"/%")
if err != nil {
return nil, err
}
defer rows.Close()
return scanLocations(rows)
}
// Create inserts a new location. ParentID and Depth are resolved from the path.
func (r *LocationRepository) Create(ctx context.Context, loc *Location) error {
// Auto-calculate depth from path segments
loc.Depth = strings.Count(loc.Path, "/")
// Resolve parent_id from path if not explicitly set
if loc.ParentID == nil && loc.Depth > 0 {
parentPath := loc.Path[:strings.LastIndex(loc.Path, "/")]
parent, err := r.GetByPath(ctx, parentPath)
if err != nil {
return fmt.Errorf("looking up parent %q: %w", parentPath, err)
}
if parent == nil {
return fmt.Errorf("parent location %q does not exist", parentPath)
}
loc.ParentID = &parent.ID
}
meta, err := json.Marshal(loc.Metadata)
if err != nil {
return fmt.Errorf("marshaling metadata: %w", err)
}
return r.db.pool.QueryRow(ctx, `
INSERT INTO locations (path, name, parent_id, location_type, depth, metadata)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at
`, loc.Path, loc.Name, loc.ParentID, loc.LocationType, loc.Depth, meta).Scan(&loc.ID, &loc.CreatedAt)
}
// Update updates a location's name, type, and metadata.
func (r *LocationRepository) Update(ctx context.Context, path string, name, locationType string, metadata map[string]any) error {
meta, err := json.Marshal(metadata)
if err != nil {
return fmt.Errorf("marshaling metadata: %w", err)
}
tag, err := r.db.pool.Exec(ctx, `
UPDATE locations
SET name = $2, location_type = $3, metadata = $4
WHERE path = $1
`, path, name, locationType, meta)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("location %q not found", path)
}
return nil
}
// Delete removes a location. Returns an error if inventory rows reference it.
func (r *LocationRepository) Delete(ctx context.Context, path string) error {
// Check for inventory references
var count int
err := r.db.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM inventory
WHERE location_id = (SELECT id FROM locations WHERE path = $1)
`, path).Scan(&count)
if err != nil {
return err
}
if count > 0 {
return fmt.Errorf("cannot delete location %q: %d inventory record(s) exist", path, count)
}
// Delete children first (cascade by path prefix), deepest first
_, err = r.db.pool.Exec(ctx, `
DELETE FROM locations
WHERE path LIKE $1
`, path+"/%")
if err != nil {
return err
}
tag, err := r.db.pool.Exec(ctx, `DELETE FROM locations WHERE path = $1`, path)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("location %q not found", path)
}
return nil
}
// HasInventory checks if a location (or descendants) have inventory records.
func (r *LocationRepository) HasInventory(ctx context.Context, path string) (bool, error) {
var count int
err := r.db.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM inventory i
JOIN locations l ON l.id = i.location_id
WHERE l.path = $1 OR l.path LIKE $2
`, path, path+"/%").Scan(&count)
return count > 0, err
}
func scanLocations(rows pgx.Rows) ([]*Location, error) {
var locs []*Location
for rows.Next() {
loc := &Location{}
var meta []byte
if err := rows.Scan(&loc.ID, &loc.Path, &loc.Name, &loc.ParentID, &loc.LocationType, &loc.Depth, &meta, &loc.CreatedAt); err != nil {
return nil, err
}
if meta != nil {
json.Unmarshal(meta, &loc.Metadata)
}
locs = append(locs, loc)
}
return locs, rows.Err()
}

View 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)
);

View 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);

View 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()
}

View 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
}

200
internal/kc/kc.go Normal file
View File

@@ -0,0 +1,200 @@
// Package kc extracts and parses the silo/ metadata directory from .kc files.
//
// A .kc file is a ZIP archive (superset of .fcstd) that contains a silo/
// directory with JSON metadata entries. This package handles extraction and
// packing — no database or HTTP dependencies.
package kc
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
)
// Manifest represents the contents of silo/manifest.json.
type Manifest struct {
UUID string `json:"uuid"`
KCVersion string `json:"kc_version"`
RevisionHash string `json:"revision_hash"`
SiloInstance string `json:"silo_instance"`
}
// Metadata represents the contents of silo/metadata.json.
type Metadata struct {
SchemaName string `json:"schema_name"`
Tags []string `json:"tags"`
LifecycleState string `json:"lifecycle_state"`
Fields map[string]any `json:"fields"`
}
// Dependency represents one entry in silo/dependencies.json.
type Dependency struct {
UUID string `json:"uuid"`
PartNumber string `json:"part_number"`
Revision int `json:"revision"`
Quantity float64 `json:"quantity"`
Label string `json:"label"`
Relationship string `json:"relationship"`
}
// MacroFile represents a script file found under silo/macros/.
type MacroFile struct {
Filename string
Content string
}
// ExtractResult holds the parsed silo/ directory contents from a .kc file.
type ExtractResult struct {
Manifest *Manifest
Metadata *Metadata
Dependencies []Dependency
Macros []MacroFile
}
// HistoryEntry represents one entry in silo/history.json.
type HistoryEntry struct {
RevisionNumber int `json:"revision_number"`
CreatedAt string `json:"created_at"`
CreatedBy *string `json:"created_by,omitempty"`
Comment *string `json:"comment,omitempty"`
Status string `json:"status"`
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 {
Manifest *Manifest
Metadata *Metadata
History []HistoryEntry
Dependencies []Dependency
Approvals []ApprovalEntry
}
// Extract opens a ZIP archive from data and parses the silo/ directory.
// Returns nil, nil if no silo/ directory is found (plain .fcstd file).
// Returns nil, error if silo/ entries exist but fail to parse.
func Extract(data []byte) (*ExtractResult, error) {
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("kc: open zip: %w", err)
}
var manifestFile, metadataFile, dependenciesFile *zip.File
var macroFiles []*zip.File
hasSiloDir := false
for _, f := range r.File {
if f.Name == "silo/" || strings.HasPrefix(f.Name, "silo/") {
hasSiloDir = true
}
switch f.Name {
case "silo/manifest.json":
manifestFile = f
case "silo/metadata.json":
metadataFile = f
case "silo/dependencies.json":
dependenciesFile = f
default:
if strings.HasPrefix(f.Name, "silo/macros/") && !f.FileInfo().IsDir() {
name := strings.TrimPrefix(f.Name, "silo/macros/")
if name != "" {
macroFiles = append(macroFiles, f)
}
}
}
}
if !hasSiloDir {
return nil, nil // plain .fcstd, no extraction
}
result := &ExtractResult{}
if manifestFile != nil {
m, err := readJSON[Manifest](manifestFile)
if err != nil {
return nil, fmt.Errorf("kc: parse manifest.json: %w", err)
}
result.Manifest = m
}
if metadataFile != nil {
m, err := readJSON[Metadata](metadataFile)
if err != nil {
return nil, fmt.Errorf("kc: parse metadata.json: %w", err)
}
result.Metadata = m
}
if dependenciesFile != nil {
deps, err := readJSON[[]Dependency](dependenciesFile)
if err != nil {
return nil, fmt.Errorf("kc: parse dependencies.json: %w", err)
}
if deps != nil {
result.Dependencies = *deps
}
}
for _, mf := range macroFiles {
rc, err := mf.Open()
if err != nil {
return nil, fmt.Errorf("kc: open macro %s: %w", mf.Name, err)
}
content, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, fmt.Errorf("kc: read macro %s: %w", mf.Name, err)
}
result.Macros = append(result.Macros, MacroFile{
Filename: strings.TrimPrefix(mf.Name, "silo/macros/"),
Content: string(content),
})
}
return result, nil
}
// readJSON opens a zip.File and decodes its contents as JSON into T.
func readJSON[T any](f *zip.File) (*T, error) {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
return nil, err
}
var v T
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return &v, nil
}

188
internal/kc/kc_test.go Normal file
View File

@@ -0,0 +1,188 @@
package kc
import (
"archive/zip"
"bytes"
"encoding/json"
"testing"
)
// buildZip creates a ZIP archive in memory from a map of filename → content.
func buildZip(t *testing.T, files map[string][]byte) []byte {
t.Helper()
var buf bytes.Buffer
w := zip.NewWriter(&buf)
for name, content := range files {
f, err := w.Create(name)
if err != nil {
t.Fatalf("creating zip entry %s: %v", name, err)
}
if _, err := f.Write(content); err != nil {
t.Fatalf("writing zip entry %s: %v", name, err)
}
}
if err := w.Close(); err != nil {
t.Fatalf("closing zip: %v", err)
}
return buf.Bytes()
}
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
data, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshaling JSON: %v", err)
}
return data
}
func TestExtract_PlainFCStd(t *testing.T) {
data := buildZip(t, map[string][]byte{
"Document.xml": []byte("<xml/>"),
"thumbnails/a.png": []byte("png"),
})
result, err := Extract(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil {
t.Fatalf("expected nil result for plain .fcstd, got %+v", result)
}
}
func TestExtract_ValidKC(t *testing.T) {
manifest := Manifest{
UUID: "550e8400-e29b-41d4-a716-446655440000",
KCVersion: "1.0",
RevisionHash: "abc123",
SiloInstance: "https://silo.example.com",
}
metadata := Metadata{
SchemaName: "mechanical-part-v2",
Tags: []string{"structural", "aluminum"},
LifecycleState: "draft",
Fields: map[string]any{
"material": "6061-T6",
"weight_kg": 0.34,
},
}
data := buildZip(t, map[string][]byte{
"Document.xml": []byte("<xml/>"),
"silo/manifest.json": mustJSON(t, manifest),
"silo/metadata.json": mustJSON(t, metadata),
})
result, err := Extract(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.Manifest == nil {
t.Fatal("expected manifest")
}
if result.Manifest.UUID != manifest.UUID {
t.Errorf("manifest UUID = %q, want %q", result.Manifest.UUID, manifest.UUID)
}
if result.Manifest.KCVersion != manifest.KCVersion {
t.Errorf("manifest KCVersion = %q, want %q", result.Manifest.KCVersion, manifest.KCVersion)
}
if result.Manifest.RevisionHash != manifest.RevisionHash {
t.Errorf("manifest RevisionHash = %q, want %q", result.Manifest.RevisionHash, manifest.RevisionHash)
}
if result.Manifest.SiloInstance != manifest.SiloInstance {
t.Errorf("manifest SiloInstance = %q, want %q", result.Manifest.SiloInstance, manifest.SiloInstance)
}
if result.Metadata == nil {
t.Fatal("expected metadata")
}
if result.Metadata.SchemaName != metadata.SchemaName {
t.Errorf("metadata SchemaName = %q, want %q", result.Metadata.SchemaName, metadata.SchemaName)
}
if result.Metadata.LifecycleState != metadata.LifecycleState {
t.Errorf("metadata LifecycleState = %q, want %q", result.Metadata.LifecycleState, metadata.LifecycleState)
}
if len(result.Metadata.Tags) != 2 {
t.Errorf("metadata Tags len = %d, want 2", len(result.Metadata.Tags))
}
if result.Metadata.Fields["material"] != "6061-T6" {
t.Errorf("metadata Fields[material] = %v, want 6061-T6", result.Metadata.Fields["material"])
}
}
func TestExtract_ManifestOnly(t *testing.T) {
manifest := Manifest{
UUID: "550e8400-e29b-41d4-a716-446655440000",
KCVersion: "1.0",
}
data := buildZip(t, map[string][]byte{
"Document.xml": []byte("<xml/>"),
"silo/manifest.json": mustJSON(t, manifest),
})
result, err := Extract(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.Manifest == nil {
t.Fatal("expected manifest")
}
if result.Metadata != nil {
t.Errorf("expected nil metadata, got %+v", result.Metadata)
}
}
func TestExtract_InvalidJSON(t *testing.T) {
data := buildZip(t, map[string][]byte{
"silo/manifest.json": []byte("{not valid json"),
})
result, err := Extract(data)
if err == nil {
t.Fatal("expected error for invalid JSON")
}
if result != nil {
t.Errorf("expected nil result on error, got %+v", result)
}
}
func TestExtract_NotAZip(t *testing.T) {
result, err := Extract([]byte("this is not a zip file"))
if err == nil {
t.Fatal("expected error for non-ZIP data")
}
if result != nil {
t.Errorf("expected nil result on error, got %+v", result)
}
}
func TestExtract_EmptySiloDir(t *testing.T) {
// silo/ directory entry exists but no manifest or metadata files
data := buildZip(t, map[string][]byte{
"Document.xml": []byte("<xml/>"),
"silo/": {},
})
result, err := Extract(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result for silo/ dir")
}
if result.Manifest != nil {
t.Errorf("expected nil manifest, got %+v", result.Manifest)
}
if result.Metadata != nil {
t.Errorf("expected nil metadata, got %+v", result.Metadata)
}
}

136
internal/kc/pack.go Normal file
View File

@@ -0,0 +1,136 @@
package kc
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
)
// HasSiloDir opens a ZIP archive and returns true if any entry starts with "silo/".
// This is a lightweight check used to short-circuit before gathering DB data.
func HasSiloDir(data []byte) (bool, error) {
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return false, fmt.Errorf("kc: open zip: %w", err)
}
for _, f := range r.File {
if f.Name == "silo/" || strings.HasPrefix(f.Name, "silo/") {
return true, nil
}
}
return false, nil
}
// Pack takes original ZIP file bytes and a PackInput, and returns new ZIP bytes
// with all silo/ entries replaced by the data from input. Non-silo entries
// (FreeCAD Document.xml, thumbnails, etc.) are copied verbatim with their
// original compression method and timestamps preserved.
//
// If the original ZIP contains no silo/ directory, the original bytes are
// returned unchanged (plain .fcstd pass-through).
func Pack(original []byte, input *PackInput) ([]byte, error) {
r, err := zip.NewReader(bytes.NewReader(original), int64(len(original)))
if err != nil {
return nil, fmt.Errorf("kc: open zip: %w", err)
}
// Partition entries into silo/ vs non-silo.
hasSilo := false
for _, f := range r.File {
if f.Name == "silo/" || strings.HasPrefix(f.Name, "silo/") {
hasSilo = true
break
}
}
if !hasSilo {
return original, nil // plain .fcstd, no repacking needed
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
// Copy all non-silo entries verbatim.
for _, f := range r.File {
if f.Name == "silo/" || strings.HasPrefix(f.Name, "silo/") {
continue
}
if err := copyZipEntry(zw, f); err != nil {
return nil, fmt.Errorf("kc: copying entry %s: %w", f.Name, err)
}
}
// Write new silo/ entries from PackInput.
if input.Manifest != nil {
if err := writeJSONEntry(zw, "silo/manifest.json", input.Manifest); err != nil {
return nil, fmt.Errorf("kc: writing manifest.json: %w", err)
}
}
if input.Metadata != nil {
if err := writeJSONEntry(zw, "silo/metadata.json", input.Metadata); err != nil {
return nil, fmt.Errorf("kc: writing metadata.json: %w", err)
}
}
if input.History != nil {
if err := writeJSONEntry(zw, "silo/history.json", input.History); err != nil {
return nil, fmt.Errorf("kc: writing history.json: %w", err)
}
}
if input.Dependencies != nil {
if err := writeJSONEntry(zw, "silo/dependencies.json", input.Dependencies); err != nil {
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)
}
return buf.Bytes(), nil
}
// copyZipEntry copies a single entry from the original ZIP to the new writer,
// preserving the file header (compression method, timestamps, etc.).
func copyZipEntry(zw *zip.Writer, f *zip.File) error {
header := f.FileHeader
w, err := zw.CreateHeader(&header)
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
_, err = io.Copy(w, rc)
return err
}
// writeJSONEntry writes a new silo/ entry as JSON with Deflate compression.
func writeJSONEntry(zw *zip.Writer, name string, v any) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
header := &zip.FileHeader{
Name: name,
Method: zip.Deflate,
}
w, err := zw.CreateHeader(header)
if err != nil {
return err
}
_, err = w.Write(data)
return err
}

229
internal/kc/pack_test.go Normal file
View File

@@ -0,0 +1,229 @@
package kc
import (
"archive/zip"
"bytes"
"io"
"testing"
)
func TestHasSiloDir_PlainFCStd(t *testing.T) {
data := buildZip(t, map[string][]byte{
"Document.xml": []byte("<xml/>"),
})
has, err := HasSiloDir(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if has {
t.Fatal("expected false for plain .fcstd")
}
}
func TestHasSiloDir_KC(t *testing.T) {
data := buildZip(t, map[string][]byte{
"Document.xml": []byte("<xml/>"),
"silo/manifest.json": []byte("{}"),
})
has, err := HasSiloDir(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !has {
t.Fatal("expected true for .kc with silo/ dir")
}
}
func TestHasSiloDir_NotAZip(t *testing.T) {
_, err := HasSiloDir([]byte("not a zip"))
if err == nil {
t.Fatal("expected error for non-ZIP data")
}
}
func TestPack_PlainFCStd_Passthrough(t *testing.T) {
original := buildZip(t, map[string][]byte{
"Document.xml": []byte("<xml/>"),
"thumbnails/a.png": []byte("png-data"),
})
result, err := Pack(original, &PackInput{
Manifest: &Manifest{UUID: "test"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Equal(result, original) {
t.Fatal("expected original bytes returned unchanged for plain .fcstd")
}
}
func TestPack_RoundTrip(t *testing.T) {
// Build a .kc with old silo/ data
oldManifest := Manifest{UUID: "old-uuid", KCVersion: "0.9", RevisionHash: "old-hash"}
oldMetadata := Metadata{SchemaName: "old-schema", Tags: []string{"old"}, LifecycleState: "draft"}
original := buildZip(t, map[string][]byte{
"Document.xml": []byte("<freecad/>"),
"thumbnails/t.png": []byte("thumb-data"),
"silo/manifest.json": mustJSON(t, oldManifest),
"silo/metadata.json": mustJSON(t, oldMetadata),
})
// Pack with new data
newManifest := &Manifest{UUID: "new-uuid", KCVersion: "1.0", RevisionHash: "new-hash", SiloInstance: "https://silo.test"}
newMetadata := &Metadata{SchemaName: "mechanical-part-v2", Tags: []string{"aluminum", "structural"}, LifecycleState: "review", Fields: map[string]any{"material": "7075-T6"}}
comment := "initial commit"
history := []HistoryEntry{
{RevisionNumber: 1, CreatedAt: "2026-01-01T00:00:00Z", Comment: &comment, Status: "draft", Labels: []string{}},
}
packed, err := Pack(original, &PackInput{
Manifest: newManifest,
Metadata: newMetadata,
History: history,
Dependencies: []Dependency{},
})
if err != nil {
t.Fatalf("Pack error: %v", err)
}
// Extract and verify new silo/ data
result, err := Extract(packed)
if err != nil {
t.Fatalf("Extract error: %v", err)
}
if result == nil {
t.Fatal("expected non-nil extract result")
}
if result.Manifest.UUID != "new-uuid" {
t.Errorf("manifest UUID = %q, want %q", result.Manifest.UUID, "new-uuid")
}
if result.Manifest.KCVersion != "1.0" {
t.Errorf("manifest KCVersion = %q, want %q", result.Manifest.KCVersion, "1.0")
}
if result.Manifest.SiloInstance != "https://silo.test" {
t.Errorf("manifest SiloInstance = %q, want %q", result.Manifest.SiloInstance, "https://silo.test")
}
if result.Metadata.SchemaName != "mechanical-part-v2" {
t.Errorf("metadata SchemaName = %q, want %q", result.Metadata.SchemaName, "mechanical-part-v2")
}
if result.Metadata.LifecycleState != "review" {
t.Errorf("metadata LifecycleState = %q, want %q", result.Metadata.LifecycleState, "review")
}
if len(result.Metadata.Tags) != 2 {
t.Errorf("metadata Tags len = %d, want 2", len(result.Metadata.Tags))
}
if result.Metadata.Fields["material"] != "7075-T6" {
t.Errorf("metadata Fields[material] = %v, want 7075-T6", result.Metadata.Fields["material"])
}
// Verify non-silo entries are preserved
r, err := zip.NewReader(bytes.NewReader(packed), int64(len(packed)))
if err != nil {
t.Fatalf("opening packed ZIP: %v", err)
}
entryMap := make(map[string]bool)
for _, f := range r.File {
entryMap[f.Name] = true
}
if !entryMap["Document.xml"] {
t.Error("Document.xml missing from packed ZIP")
}
if !entryMap["thumbnails/t.png"] {
t.Error("thumbnails/t.png missing from packed ZIP")
}
// Verify non-silo content is byte-identical
for _, f := range r.File {
if f.Name == "Document.xml" {
content := readZipEntry(t, f)
if string(content) != "<freecad/>" {
t.Errorf("Document.xml content = %q, want %q", content, "<freecad/>")
}
}
if f.Name == "thumbnails/t.png" {
content := readZipEntry(t, f)
if string(content) != "thumb-data" {
t.Errorf("thumbnails/t.png content = %q, want %q", content, "thumb-data")
}
}
}
}
func TestPack_NilFields(t *testing.T) {
original := buildZip(t, map[string][]byte{
"Document.xml": []byte("<xml/>"),
"silo/manifest.json": []byte(`{"uuid":"x"}`),
})
// Pack with only manifest, nil metadata/history/deps
packed, err := Pack(original, &PackInput{
Manifest: &Manifest{UUID: "updated"},
})
if err != nil {
t.Fatalf("Pack error: %v", err)
}
// Extract — should have manifest but no metadata
result, err := Extract(packed)
if err != nil {
t.Fatalf("Extract error: %v", err)
}
if result.Manifest == nil || result.Manifest.UUID != "updated" {
t.Errorf("manifest UUID = %v, want updated", result.Manifest)
}
if result.Metadata != nil {
t.Errorf("expected nil metadata, got %+v", result.Metadata)
}
// Verify no old silo/ entries leaked through
r, _ := zip.NewReader(bytes.NewReader(packed), int64(len(packed)))
for _, f := range r.File {
if f.Name == "silo/metadata.json" {
t.Error("old silo/metadata.json should have been removed")
}
}
}
func TestPack_EmptyDependencies(t *testing.T) {
original := buildZip(t, map[string][]byte{
"silo/manifest.json": []byte(`{"uuid":"x"}`),
})
packed, err := Pack(original, &PackInput{
Manifest: &Manifest{UUID: "x"},
Dependencies: []Dependency{},
})
if err != nil {
t.Fatalf("Pack error: %v", err)
}
// Verify dependencies.json exists and is []
r, _ := zip.NewReader(bytes.NewReader(packed), int64(len(packed)))
for _, f := range r.File {
if f.Name == "silo/dependencies.json" {
content := readZipEntry(t, f)
if string(content) != "[]" {
t.Errorf("dependencies.json = %q, want %q", content, "[]")
}
return
}
}
t.Error("silo/dependencies.json not found in packed ZIP")
}
// readZipEntry reads the full contents of a zip.File.
func readZipEntry(t *testing.T, f *zip.File) []byte {
t.Helper()
rc, err := f.Open()
if err != nil {
t.Fatalf("opening zip entry %s: %v", f.Name, err)
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("reading zip entry %s: %v", f.Name, err)
}
return data
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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.

View File

@@ -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.

View File

@@ -0,0 +1,177 @@
package storage
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"time"
)
// ErrPresignNotSupported is returned when presigned URLs are requested from a
// backend that does not support them.
var ErrPresignNotSupported = errors.New("presigned URLs not supported by filesystem backend")
// Compile-time check: *FilesystemStore implements FileStore.
var _ FileStore = (*FilesystemStore)(nil)
// FilesystemStore stores objects as files under a root directory.
type FilesystemStore struct {
root string // absolute path
}
// NewFilesystemStore creates a new filesystem-backed store rooted at root.
// The directory is created if it does not exist.
func NewFilesystemStore(root string) (*FilesystemStore, error) {
abs, err := filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("resolving root path: %w", err)
}
if err := os.MkdirAll(abs, 0o755); err != nil {
return nil, fmt.Errorf("creating root directory: %w", err)
}
return &FilesystemStore{root: abs}, nil
}
// path returns the absolute filesystem path for a storage key.
func (fs *FilesystemStore) path(key string) string {
return filepath.Join(fs.root, filepath.FromSlash(key))
}
// Put writes reader to the file at key using atomic rename.
// SHA-256 checksum is computed during write and returned in PutResult.
func (fs *FilesystemStore) Put(_ context.Context, key string, reader io.Reader, _ int64, _ string) (*PutResult, error) {
dest := fs.path(key)
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return nil, fmt.Errorf("creating directories: %w", err)
}
// Write to a temp file in the same directory so os.Rename is atomic.
tmp, err := os.CreateTemp(filepath.Dir(dest), ".silo-tmp-*")
if err != nil {
return nil, fmt.Errorf("creating temp file: %w", err)
}
tmpPath := tmp.Name()
defer func() {
// Clean up temp file on any failure path.
tmp.Close()
os.Remove(tmpPath)
}()
h := sha256.New()
w := io.MultiWriter(tmp, h)
n, err := io.Copy(w, reader)
if err != nil {
return nil, fmt.Errorf("writing file: %w", err)
}
if err := tmp.Close(); err != nil {
return nil, fmt.Errorf("closing temp file: %w", err)
}
if err := os.Rename(tmpPath, dest); err != nil {
return nil, fmt.Errorf("renaming temp file: %w", err)
}
return &PutResult{
Key: key,
Size: n,
Checksum: hex.EncodeToString(h.Sum(nil)),
}, nil
}
// Get opens the file at key for reading.
func (fs *FilesystemStore) Get(_ context.Context, key string) (io.ReadCloser, error) {
f, err := os.Open(fs.path(key))
if err != nil {
return nil, fmt.Errorf("opening file: %w", err)
}
return f, nil
}
// GetVersion delegates to Get — filesystem storage has no versioning.
func (fs *FilesystemStore) GetVersion(ctx context.Context, key string, _ string) (io.ReadCloser, error) {
return fs.Get(ctx, key)
}
// Delete removes the file at key. No error if already absent.
func (fs *FilesystemStore) Delete(_ context.Context, key string) error {
err := os.Remove(fs.path(key))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("removing file: %w", err)
}
return nil
}
// Exists reports whether the file at key exists.
func (fs *FilesystemStore) Exists(_ context.Context, key string) (bool, error) {
_, err := os.Stat(fs.path(key))
if err == nil {
return true, nil
}
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, fmt.Errorf("checking file: %w", err)
}
// Copy duplicates a file from srcKey to dstKey using atomic rename.
func (fs *FilesystemStore) Copy(_ context.Context, srcKey, dstKey string) error {
srcPath := fs.path(srcKey)
dstPath := fs.path(dstKey)
src, err := os.Open(srcPath)
if err != nil {
return fmt.Errorf("opening source: %w", err)
}
defer src.Close()
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return fmt.Errorf("creating directories: %w", err)
}
tmp, err := os.CreateTemp(filepath.Dir(dstPath), ".silo-tmp-*")
if err != nil {
return fmt.Errorf("creating temp file: %w", err)
}
tmpPath := tmp.Name()
defer func() {
tmp.Close()
os.Remove(tmpPath)
}()
if _, err := io.Copy(tmp, src); err != nil {
return fmt.Errorf("copying file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("closing temp file: %w", err)
}
if err := os.Rename(tmpPath, dstPath); err != nil {
return fmt.Errorf("renaming temp file: %w", err)
}
return nil
}
// PresignPut is not supported by the filesystem backend.
func (fs *FilesystemStore) PresignPut(_ context.Context, _ string, _ time.Duration) (*url.URL, error) {
return nil, ErrPresignNotSupported
}
// Ping verifies the root directory is accessible and writable.
func (fs *FilesystemStore) Ping(_ context.Context) error {
tmp, err := os.CreateTemp(fs.root, ".silo-ping-*")
if err != nil {
return fmt.Errorf("storage ping failed: %w", err)
}
name := tmp.Name()
tmp.Close()
os.Remove(name)
return nil
}

View File

@@ -0,0 +1,277 @@
package storage
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func newTestStore(t *testing.T) *FilesystemStore {
t.Helper()
fs, err := NewFilesystemStore(t.TempDir())
if err != nil {
t.Fatalf("NewFilesystemStore: %v", err)
}
return fs
}
func TestNewFilesystemStore(t *testing.T) {
dir := t.TempDir()
sub := filepath.Join(dir, "a", "b")
fs, err := NewFilesystemStore(sub)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !filepath.IsAbs(fs.root) {
t.Errorf("root is not absolute: %s", fs.root)
}
info, err := os.Stat(sub)
if err != nil {
t.Fatalf("root dir missing: %v", err)
}
if !info.IsDir() {
t.Error("root is not a directory")
}
}
func TestPut(t *testing.T) {
fs := newTestStore(t)
ctx := context.Background()
data := []byte("hello world")
h := sha256.Sum256(data)
wantChecksum := hex.EncodeToString(h[:])
result, err := fs.Put(ctx, "items/P001/rev1.FCStd", bytes.NewReader(data), int64(len(data)), "application/octet-stream")
if err != nil {
t.Fatalf("Put: %v", err)
}
if result.Key != "items/P001/rev1.FCStd" {
t.Errorf("Key = %q, want %q", result.Key, "items/P001/rev1.FCStd")
}
if result.Size != int64(len(data)) {
t.Errorf("Size = %d, want %d", result.Size, len(data))
}
if result.Checksum != wantChecksum {
t.Errorf("Checksum = %q, want %q", result.Checksum, wantChecksum)
}
// Verify file on disk.
got, err := os.ReadFile(fs.path("items/P001/rev1.FCStd"))
if err != nil {
t.Fatalf("reading file: %v", err)
}
if !bytes.Equal(got, data) {
t.Error("file content mismatch")
}
}
func TestPutAtomicity(t *testing.T) {
fs := newTestStore(t)
ctx := context.Background()
key := "test/atomic.bin"
// Write an initial file.
if _, err := fs.Put(ctx, key, strings.NewReader("original"), 8, ""); err != nil {
t.Fatalf("initial Put: %v", err)
}
// Write with a reader that fails partway through.
failing := io.MultiReader(strings.NewReader("partial"), &errReader{})
_, err := fs.Put(ctx, key, failing, 100, "")
if err == nil {
t.Fatal("expected error from failing reader")
}
// Original file should still be intact.
got, err := os.ReadFile(fs.path(key))
if err != nil {
t.Fatalf("reading file after failed put: %v", err)
}
if string(got) != "original" {
t.Errorf("file content = %q, want %q", got, "original")
}
}
type errReader struct{}
func (e *errReader) Read([]byte) (int, error) {
return 0, io.ErrUnexpectedEOF
}
func TestGet(t *testing.T) {
fs := newTestStore(t)
ctx := context.Background()
data := []byte("test content")
if _, err := fs.Put(ctx, "f.txt", bytes.NewReader(data), int64(len(data)), ""); err != nil {
t.Fatalf("Put: %v", err)
}
rc, err := fs.Get(ctx, "f.txt")
if err != nil {
t.Fatalf("Get: %v", err)
}
defer rc.Close()
got, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("ReadAll: %v", err)
}
if !bytes.Equal(got, data) {
t.Error("content mismatch")
}
}
func TestGetMissing(t *testing.T) {
fs := newTestStore(t)
_, err := fs.Get(context.Background(), "no/such/file")
if err == nil {
t.Fatal("expected error for missing file")
}
}
func TestGetVersion(t *testing.T) {
fs := newTestStore(t)
ctx := context.Background()
data := []byte("versioned")
if _, err := fs.Put(ctx, "v.txt", bytes.NewReader(data), int64(len(data)), ""); err != nil {
t.Fatalf("Put: %v", err)
}
// GetVersion ignores versionID, returns same file.
rc, err := fs.GetVersion(ctx, "v.txt", "ignored-version-id")
if err != nil {
t.Fatalf("GetVersion: %v", err)
}
defer rc.Close()
got, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("ReadAll: %v", err)
}
if !bytes.Equal(got, data) {
t.Error("content mismatch")
}
}
func TestDelete(t *testing.T) {
fs := newTestStore(t)
ctx := context.Background()
if _, err := fs.Put(ctx, "del.txt", strings.NewReader("x"), 1, ""); err != nil {
t.Fatalf("Put: %v", err)
}
if err := fs.Delete(ctx, "del.txt"); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := os.Stat(fs.path("del.txt")); !os.IsNotExist(err) {
t.Error("file still exists after delete")
}
}
func TestDeleteMissing(t *testing.T) {
fs := newTestStore(t)
if err := fs.Delete(context.Background(), "no/such/file"); err != nil {
t.Fatalf("Delete missing file should not error: %v", err)
}
}
func TestExists(t *testing.T) {
fs := newTestStore(t)
ctx := context.Background()
ok, err := fs.Exists(ctx, "nope")
if err != nil {
t.Fatalf("Exists: %v", err)
}
if ok {
t.Error("Exists returned true for missing file")
}
if _, err := fs.Put(ctx, "yes.txt", strings.NewReader("y"), 1, ""); err != nil {
t.Fatalf("Put: %v", err)
}
ok, err = fs.Exists(ctx, "yes.txt")
if err != nil {
t.Fatalf("Exists: %v", err)
}
if !ok {
t.Error("Exists returned false for existing file")
}
}
func TestCopy(t *testing.T) {
fs := newTestStore(t)
ctx := context.Background()
data := []byte("copy me")
if _, err := fs.Put(ctx, "src.bin", bytes.NewReader(data), int64(len(data)), ""); err != nil {
t.Fatalf("Put: %v", err)
}
if err := fs.Copy(ctx, "src.bin", "deep/nested/dst.bin"); err != nil {
t.Fatalf("Copy: %v", err)
}
got, err := os.ReadFile(fs.path("deep/nested/dst.bin"))
if err != nil {
t.Fatalf("reading copied file: %v", err)
}
if !bytes.Equal(got, data) {
t.Error("copied content mismatch")
}
// Source should still exist.
if _, err := os.Stat(fs.path("src.bin")); err != nil {
t.Error("source file missing after copy")
}
}
func TestPresignPut(t *testing.T) {
fs := newTestStore(t)
_, err := fs.PresignPut(context.Background(), "key", 5*60)
if err != ErrPresignNotSupported {
t.Errorf("PresignPut error = %v, want ErrPresignNotSupported", err)
}
}
func TestPing(t *testing.T) {
fs := newTestStore(t)
if err := fs.Ping(context.Background()); err != nil {
t.Fatalf("Ping: %v", err)
}
}
func TestPingBadRoot(t *testing.T) {
fs := &FilesystemStore{root: "/nonexistent/path/that/should/not/exist"}
if err := fs.Ping(context.Background()); err == nil {
t.Fatal("expected Ping to fail with invalid root")
}
}
func TestPutOverwrite(t *testing.T) {
fs := newTestStore(t)
ctx := context.Background()
if _, err := fs.Put(ctx, "ow.txt", strings.NewReader("first"), 5, ""); err != nil {
t.Fatalf("Put: %v", err)
}
if _, err := fs.Put(ctx, "ow.txt", strings.NewReader("second"), 6, ""); err != nil {
t.Fatalf("Put overwrite: %v", err)
}
got, _ := os.ReadFile(fs.path("ow.txt"))
if string(got) != "second" {
t.Errorf("content = %q, want %q", got, "second")
}
}

View File

@@ -0,0 +1,40 @@
// Package storage defines the FileStore interface and backend implementations.
package storage
import (
"context"
"fmt"
"io"
"net/url"
"time"
)
// FileStore is the interface for file storage backends.
type FileStore interface {
Put(ctx context.Context, key string, reader io.Reader, size int64, contentType string) (*PutResult, error)
Get(ctx context.Context, key string) (io.ReadCloser, error)
GetVersion(ctx context.Context, key string, versionID string) (io.ReadCloser, error)
Delete(ctx context.Context, key string) error
Exists(ctx context.Context, key string) (bool, error)
Copy(ctx context.Context, srcKey, dstKey string) error
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)
}

View File

@@ -1,159 +0,0 @@
// Package storage provides MinIO file storage operations.
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
}
// 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
}
// 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)
}

View File

@@ -79,12 +79,13 @@ func TruncateAll(t *testing.T, pool *pgxpool.Pool) {
_, err := pool.Exec(context.Background(), `
TRUNCATE
item_metadata, item_dependencies, approval_signatures, item_approvals, item_macros,
settings_overrides, module_state,
job_log, jobs, job_definitions, runners,
dag_cross_edges, dag_edges, dag_nodes,
audit_log, sync_log, api_tokens, sessions, item_files,
item_projects, relationships, revisions, inventory, items,
projects, sequences_by_name, users, property_migrations
locations, projects, sequences_by_name, users, property_migrations
CASCADE
`)
if err != nil {

View 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
}

View 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")
}
}

View 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

View 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

View File

@@ -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

View File

@@ -0,0 +1,7 @@
-- Track which storage backend holds each attached file.
ALTER TABLE item_files
ADD COLUMN IF NOT EXISTS storage_backend TEXT NOT NULL DEFAULT 'minio';
-- Track which storage backend holds each revision file.
ALTER TABLE revisions
ADD COLUMN IF NOT EXISTS file_storage_backend TEXT NOT NULL DEFAULT 'minio';

View 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;

View 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';

View 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';

View 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;

View File

@@ -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)
@@ -77,6 +76,9 @@ if systemctl is-active --quiet silod 2>/dev/null; then
sudo systemctl stop silod
fi
# Clean old frontend assets before extracting
sudo rm -rf "$DEPLOY_DIR/web/dist/assets"
# Extract
echo " Extracting..."
sudo tar -xzf /tmp/silo-deploy.tar.gz -C "$DEPLOY_DIR"

View File

@@ -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)"

View File

@@ -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:"

View File

@@ -1,12 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Silo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Silo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

106
web/public/favicon.svg Normal file
View File

@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="1028"
height="1028"
viewBox="0 0 271.99167 271.99167"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (2aeb623e1d, 2025-05-12)"
sodipodi:docname="kindred-logo.svg"
inkscape:export-filename="../3290ed6b/kindred-logo-blue-baack.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="1.036062"
inkscape:cx="397.6596"
inkscape:cy="478.25323"
inkscape:window-width="2494"
inkscape:window-height="1371"
inkscape:window-x="1146"
inkscape:window-y="1112"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
inkscape:export-bgcolor="#79c0c500">
<inkscape:grid
type="axonomgrid"
id="grid6"
units="mm"
originx="0"
originy="0"
spacingx="0.99999998"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="5"
dotted="false"
gridanglex="30"
gridanglez="30"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1">
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="0 : 123.49166 : 1"
inkscape:vp_y="0 : 999.99998 : 0"
inkscape:vp_z="210.00001 : 123.49166 : 1"
inkscape:persp3d-origin="105 : 73.991665 : 1"
id="perspective1" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
sodipodi:type="star"
style="fill:#7c4a82;fill-opacity:1;stroke:#12101c;stroke-width:5;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
id="path6-81-5"
inkscape:flatsided="true"
sodipodi:sides="6"
sodipodi:cx="61.574867"
sodipodi:cy="103.99491"
sodipodi:r1="25.000006"
sodipodi:r2="22.404818"
sodipodi:arg1="-1.5707963"
sodipodi:arg2="-1.0471974"
inkscape:rounded="0.77946499"
inkscape:randomized="0"
d="m 61.574868,78.994905 c 19.486629,10e-7 11.907325,-4.375912 21.65064,12.500004 9.743314,16.875911 9.743314,8.12409 -1e-6,25.000001 -9.743315,16.87592 -2.164011,12.50001 -21.65064,12.50001 -19.486629,0 -11.907326,4.37591 -21.65064,-12.50001 -9.743314,-16.875912 -9.743314,-8.12409 0,-25.000002 9.743315,-16.875916 2.164012,-12.500003 21.650641,-12.500003 z"
transform="matrix(1.9704344,0,0,1.8525167,-28.510585,-40.025402)" />
<path
sodipodi:type="star"
style="fill:#ff9701;fill-opacity:1;stroke:#12101c;stroke-width:5;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
id="path6-81-5-6"
inkscape:flatsided="true"
sodipodi:sides="6"
sodipodi:cx="61.574867"
sodipodi:cy="103.99491"
sodipodi:r1="25.000006"
sodipodi:r2="22.404818"
sodipodi:arg1="-1.5707963"
sodipodi:arg2="-1.0471974"
inkscape:rounded="0.77946499"
inkscape:randomized="0"
d="m 61.574868,78.994905 c 19.486629,10e-7 11.907325,-4.375912 21.65064,12.500004 9.743314,16.875921 9.743314,8.12409 -1e-6,25.000001 -9.743315,16.87592 -2.164011,12.50001 -21.65064,12.50001 -19.48663,0 -11.907326,4.37591 -21.65064,-12.50001 -9.743314,-16.875913 -9.743315,-8.12409 10e-7,-25.000002 9.743315,-16.875916 2.164011,-12.500003 21.65064,-12.500003 z"
transform="matrix(1.9704344,0,0,1.8525167,56.811738,-86.338327)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -352,6 +352,35 @@ export interface UpdateSchemaValueRequest {
description: string;
}
// Admin settings — module discovery
export interface ModuleInfo {
enabled: boolean;
required: boolean;
name: string;
version?: string;
depends_on?: string[];
config?: Record<string, unknown>;
}
export interface ModulesResponse {
modules: Record<string, ModuleInfo>;
server: { version: string; read_only: boolean };
}
// Admin settings — config management
export type AdminSettingsResponse = Record<string, Record<string, unknown>>;
export interface UpdateSettingsResponse {
updated: string[];
restart_required: boolean;
}
export interface TestConnectivityResponse {
success: boolean;
message: string;
latency_ms: number;
}
// Revision comparison
export interface RevisionComparison {
from: number;

View File

@@ -1,24 +1,67 @@
import { NavLink, Outlet } from "react-router-dom";
import { useCallback, useEffect, useState } from "react";
import { Outlet } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
import { useDensity } from "../hooks/useDensity";
const navLinks = [
{ to: "/", label: "Items" },
{ to: "/projects", label: "Projects" },
{ to: "/schemas", label: "Schemas" },
{ to: "/audit", label: "Audit" },
{ to: "/settings", label: "Settings" },
];
const roleBadgeStyle: Record<string, React.CSSProperties> = {
admin: { background: "rgba(203,166,247,0.2)", color: "var(--ctp-mauve)" },
editor: { background: "rgba(137,180,250,0.2)", color: "var(--ctp-blue)" },
viewer: { background: "rgba(148,226,213,0.2)", color: "var(--ctp-teal)" },
};
import { useModules } from "../hooks/useModules";
import { useSSE } from "../hooks/useSSE";
import { Sidebar } from "./Sidebar";
export function AppShell() {
const { user, loading, logout } = useAuth();
const [density, toggleDensity] = useDensity();
const { modules, refresh: refreshModules } = useModules();
const { on } = useSSE();
const [toast, setToast] = useState<string | null>(null);
// Listen for settings.changed SSE events
useEffect(() => {
return on("settings.changed", (raw) => {
try {
const data = JSON.parse(raw) as {
module: string;
changed_keys: string[];
updated_by: string;
};
refreshModules();
if (data.updated_by !== user?.username) {
setToast(`Settings updated by ${data.updated_by}`);
}
} catch {
// ignore malformed events
}
});
}, [on, refreshModules, user?.username]);
// Auto-dismiss toast
useEffect(() => {
if (!toast) return;
const timer = setTimeout(() => setToast(null), 5000);
return () => clearTimeout(timer);
}, [toast]);
const [sidebarOpen, setSidebarOpen] = useState(() => {
return localStorage.getItem("silo-sidebar") !== "closed";
});
const toggleSidebar = useCallback(() => {
setSidebarOpen((prev) => {
const next = !prev;
localStorage.setItem("silo-sidebar", next ? "open" : "closed");
return next;
});
}, []);
// Ctrl+J to toggle sidebar
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === "j") {
e.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [toggleSidebar]);
if (loading) {
return (
@@ -36,119 +79,40 @@ export function AppShell() {
}
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
<header
style={{
backgroundColor: "var(--ctp-mantle)",
borderBottom: "1px solid var(--ctp-surface0)",
padding: "var(--d-header-py) var(--d-header-px)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexShrink: 0,
}}
>
<h1
style={{
fontSize: "var(--d-header-logo)",
fontWeight: 600,
color: "var(--ctp-mauve)",
}}
>
Silo
</h1>
<nav style={{ display: "flex", gap: "var(--d-nav-gap)" }}>
{navLinks.map((link) => (
<NavLink
key={link.to}
to={link.to}
end={link.to === "/"}
style={({ isActive }) => ({
color: isActive ? "var(--ctp-mauve)" : "var(--ctp-subtext1)",
backgroundColor: isActive
? "var(--ctp-surface1)"
: "transparent",
fontWeight: 500,
padding: "var(--d-nav-py) var(--d-nav-px)",
borderRadius: "var(--d-nav-radius)",
textDecoration: "none",
transition: "all 0.15s ease",
})}
>
{link.label}
</NavLink>
))}
</nav>
{user && (
<div
style={{
display: "flex",
alignItems: "center",
gap: "var(--d-user-gap)",
}}
>
<span
style={{
color: "var(--ctp-subtext1)",
fontSize: "var(--d-user-font)",
}}
>
{user.display_name}
</span>
<span
style={{
display: "inline-block",
padding: "0.25rem 0.5rem",
borderRadius: "1rem",
fontSize: "0.75rem",
fontWeight: 600,
...roleBadgeStyle[user.role],
}}
>
{user.role}
</span>
<button
onClick={toggleDensity}
title={`Switch to ${density === "comfortable" ? "compact" : "comfortable"} view`}
style={{
padding: "0.25rem 0.5rem",
fontSize: "var(--font-sm)",
borderRadius: "0.25rem",
cursor: "pointer",
border: "1px solid var(--ctp-surface1)",
background: "var(--ctp-surface0)",
color: "var(--ctp-subtext1)",
fontFamily: "'JetBrains Mono', monospace",
letterSpacing: "0.05em",
}}
>
{density === "comfortable" ? "COM" : "CMP"}
</button>
<button
onClick={logout}
style={{
padding: "0.25rem 0.75rem",
fontSize: "var(--font-table)",
borderRadius: "0.5rem",
cursor: "pointer",
border: "none",
background: "var(--ctp-surface1)",
color: "var(--ctp-subtext1)",
}}
>
Logout
</button>
</div>
)}
</header>
<main
style={{ flex: 1, padding: "1rem 1rem 0 1rem", overflow: "hidden" }}
>
<div style={{ display: "flex", height: "100vh" }}>
<Sidebar
open={sidebarOpen}
onToggle={toggleSidebar}
modules={modules}
user={user}
density={density}
onToggleDensity={toggleDensity}
onLogout={logout}
/>
<main style={{ flex: 1, overflow: "auto", padding: "1rem" }}>
<Outlet />
</main>
{toast && (
<div
style={{
position: "fixed",
bottom: "1rem",
right: "1rem",
padding: "0.5rem 1rem",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
borderRadius: "0.5rem",
fontSize: "var(--font-body)",
border: "1px solid var(--ctp-surface2)",
boxShadow: "0 2px 8px rgba(0,0,0,0.3)",
zIndex: 1000,
cursor: "pointer",
}}
onClick={() => setToast(null)}
>
{toast}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,335 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { NavLink, useNavigate } from "react-router-dom";
import {
Package,
FolderKanban,
FileCode2,
ClipboardCheck,
Settings2,
ChevronLeft,
ChevronRight,
LogOut,
} from "lucide-react";
import type { ModuleInfo } from "../api/types";
interface NavItem {
moduleId: string | null;
path: string;
label: string;
icon: React.ComponentType<{ size?: number }>;
}
const allNavItems: NavItem[] = [
{ moduleId: "core", path: "/", label: "Items", icon: Package },
{
moduleId: "projects",
path: "/projects",
label: "Projects",
icon: FolderKanban,
},
{ moduleId: "schemas", path: "/schemas", label: "Schemas", icon: FileCode2 },
{ moduleId: "audit", path: "/audit", label: "Audit", icon: ClipboardCheck },
{ moduleId: null, path: "/settings", label: "Settings", icon: Settings2 },
];
interface SidebarProps {
open: boolean;
onToggle: () => void;
modules: Record<string, ModuleInfo>;
user: { display_name: string; role: string } | null;
density: string;
onToggleDensity: () => void;
onLogout: () => void;
}
const roleBadgeStyle: Record<string, React.CSSProperties> = {
admin: { background: "rgba(203,166,247,0.2)", color: "var(--ctp-mauve)" },
editor: { background: "rgba(137,180,250,0.2)", color: "var(--ctp-blue)" },
viewer: { background: "rgba(148,226,213,0.2)", color: "var(--ctp-teal)" },
};
export function Sidebar({
open,
onToggle,
modules,
user,
density,
onToggleDensity,
onLogout,
}: SidebarProps) {
const navigate = useNavigate();
const [focusIndex, setFocusIndex] = useState(-1);
const navRefs = useRef<(HTMLAnchorElement | null)[]>([]);
const visibleItems = allNavItems.filter(
(item) => item.moduleId === null || modules[item.moduleId]?.enabled,
);
// Focus the item at focusIndex when it changes
useEffect(() => {
if (focusIndex >= 0 && focusIndex < navRefs.current.length) {
navRefs.current[focusIndex]?.focus();
}
}, [focusIndex]);
// Reset focus when sidebar closes
useEffect(() => {
if (!open) setFocusIndex(-1);
}, [open]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!open) return;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setFocusIndex((i) => (i + 1) % visibleItems.length);
break;
case "ArrowUp":
e.preventDefault();
setFocusIndex(
(i) => (i - 1 + visibleItems.length) % visibleItems.length,
);
break;
case "Enter": {
const target = visibleItems[focusIndex];
if (focusIndex >= 0 && target) {
e.preventDefault();
navigate(target.path);
}
break;
}
case "Escape":
e.preventDefault();
onToggle();
break;
}
},
[open, focusIndex, visibleItems, navigate, onToggle],
);
return (
<nav
onKeyDown={handleKeyDown}
style={{
width: open ? "var(--d-sidebar-w)" : "var(--d-sidebar-collapsed)",
minWidth: open ? "var(--d-sidebar-w)" : "var(--d-sidebar-collapsed)",
height: "100vh",
backgroundColor: "var(--ctp-mantle)",
borderRight: "1px solid var(--ctp-surface0)",
display: "flex",
flexDirection: "column",
transition: "width 0.2s ease, min-width 0.2s ease",
overflow: "hidden",
flexShrink: 0,
}}
>
{/* Logo */}
<div
style={{
padding: open ? "0.75rem 1rem" : "0.75rem 0",
display: "flex",
alignItems: "center",
justifyContent: open ? "flex-start" : "center",
borderBottom: "1px solid var(--ctp-surface0)",
minHeight: 44,
}}
>
<span
style={{
fontSize: "1.25rem",
fontWeight: 700,
color: "var(--ctp-mauve)",
whiteSpace: "nowrap",
}}
>
{open ? "Silo" : "S"}
</span>
</div>
{/* Nav items */}
<div
style={{
flex: 1,
padding: "0.5rem 0.5rem",
display: "flex",
flexDirection: "column",
gap: "2px",
}}
>
{visibleItems.map((item, i) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === "/"}
ref={(el) => {
navRefs.current[i] = el;
}}
title={open ? undefined : item.label}
style={({ isActive }) => ({
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "var(--d-nav-py) var(--d-nav-px)",
borderRadius: "var(--d-nav-radius)",
textDecoration: "none",
color: isActive ? "var(--ctp-mauve)" : "var(--ctp-subtext1)",
backgroundColor: isActive ? "var(--ctp-surface1)" : "transparent",
fontWeight: 500,
fontSize: "var(--font-body)",
whiteSpace: "nowrap",
transition: "background-color 0.15s ease, color 0.15s ease",
outline: focusIndex === i ? "1px solid var(--ctp-mauve)" : "none",
outlineOffset: -1,
justifyContent: open ? "flex-start" : "center",
})}
onMouseEnter={(e) => {
const target = e.currentTarget;
if (
!target.style.backgroundColor ||
target.style.backgroundColor === "transparent"
) {
target.style.backgroundColor = "var(--ctp-surface0)";
}
}}
onMouseLeave={(e) => {
const target = e.currentTarget;
// Let NavLink's isActive styling handle active items
const isActive = target.getAttribute("aria-current") === "page";
if (!isActive) {
target.style.backgroundColor = "transparent";
}
}}
>
<item.icon size={16} />
{open && <span>{item.label}</span>}
</NavLink>
))}
</div>
{/* Bottom section */}
<div
style={{
borderTop: "1px solid var(--ctp-surface0)",
padding: "0.5rem",
display: "flex",
flexDirection: "column",
gap: "4px",
}}
>
{/* Toggle sidebar */}
<button
onClick={onToggle}
title={open ? "Collapse sidebar (Ctrl+J)" : "Expand sidebar (Ctrl+J)"}
style={{
...btnStyle,
justifyContent: open ? "flex-start" : "center",
}}
>
{open ? <ChevronLeft size={16} /> : <ChevronRight size={16} />}
{open && <span>Collapse</span>}
</button>
{/* Density toggle */}
<button
onClick={onToggleDensity}
title={`Switch to ${density === "comfortable" ? "compact" : "comfortable"} view`}
style={{
...btnStyle,
justifyContent: open ? "flex-start" : "center",
fontFamily: "'JetBrains Mono', monospace",
letterSpacing: "0.05em",
}}
>
<span
style={{
width: 16,
textAlign: "center",
fontSize: "var(--font-sm)",
}}
>
{density === "comfortable" ? "CO" : "CP"}
</span>
{open && (
<span>{density === "comfortable" ? "Comfortable" : "Compact"}</span>
)}
</button>
{/* User */}
{user && (
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.375rem var(--d-nav-px)",
justifyContent: open ? "flex-start" : "center",
}}
>
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 20,
height: 20,
borderRadius: "50%",
fontSize: "var(--font-xs)",
fontWeight: 600,
flexShrink: 0,
...roleBadgeStyle[user.role],
}}
>
{user.role.charAt(0).toUpperCase()}
</span>
{open && (
<span
style={{
color: "var(--ctp-subtext1)",
fontSize: "var(--font-body)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{user.display_name}
</span>
)}
</div>
)}
{/* Logout */}
<button
onClick={onLogout}
title="Logout"
style={{
...btnStyle,
justifyContent: open ? "flex-start" : "center",
color: "var(--ctp-overlay1)",
}}
>
<LogOut size={16} />
{open && <span>Logout</span>}
</button>
</div>
</nav>
);
}
const btnStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "var(--d-nav-py) var(--d-nav-px)",
borderRadius: "var(--d-nav-radius)",
border: "none",
background: "transparent",
color: "var(--ctp-subtext1)",
fontSize: "var(--font-body)",
fontWeight: 500,
cursor: "pointer",
whiteSpace: "nowrap",
width: "100%",
textAlign: "left",
};

View File

@@ -1,5 +1,5 @@
import { useState, useCallback } from "react";
import { get, post, put } from "../../api/client";
import { get, post } from "../../api/client";
import type {
Project,
FormFieldDescriptor,
@@ -95,34 +95,9 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
[],
);
const handleFilesAdded = useCallback(
(files: PendingAttachment[]) => {
const startIdx = attachments.length;
setAttachments((prev) => [...prev, ...files]);
files.forEach((f, i) => {
const idx = startIdx + i;
setAttachments((prev) =>
prev.map((a, j) =>
j === idx ? { ...a, uploadStatus: "uploading" } : a,
),
);
upload(f.file, (progress) => {
setAttachments((prev) =>
prev.map((a, j) =>
j === idx ? { ...a, uploadProgress: progress } : a,
),
);
}).then((result) => {
setAttachments((prev) =>
prev.map((a, j) => (j === idx ? result : a)),
);
});
});
},
[attachments.length, upload],
);
const handleFilesAdded = useCallback((files: PendingAttachment[]) => {
setAttachments((prev) => [...prev, ...files]);
}, []);
const handleFileRemoved = useCallback((index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index));
@@ -136,24 +111,15 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
const file = input.files?.[0];
if (!file) return;
const pending: PendingAttachment = {
setThumbnailFile({
file,
objectKey: "",
uploadProgress: 0,
uploadStatus: "uploading",
};
setThumbnailFile(pending);
upload(file, (progress) => {
setThumbnailFile((prev) =>
prev ? { ...prev, uploadProgress: progress } : null,
);
}).then((result) => {
setThumbnailFile(result);
uploadStatus: "pending",
});
};
input.click();
}, [upload]);
}, []);
const handleSubmit = async () => {
if (!category) {
@@ -188,33 +154,24 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
});
const pn = result.part_number;
const encodedPN = encodeURIComponent(pn);
// Associate uploaded attachments.
const completed = attachments.filter(
(a) => a.uploadStatus === "complete" && a.objectKey,
);
for (const att of completed) {
// Upload attachments via direct multipart POST.
for (const att of attachments) {
try {
await post(`/api/items/${encodeURIComponent(pn)}/files`, {
object_key: att.objectKey,
filename: att.file.name,
content_type: att.file.type || "application/octet-stream",
size: att.file.size,
});
await upload(att.file, `/api/items/${encodedPN}/files/upload`);
} catch {
// File association failure is non-blocking.
// File upload failure is non-blocking.
}
}
// Set thumbnail.
if (
thumbnailFile?.uploadStatus === "complete" &&
thumbnailFile.objectKey
) {
// Upload thumbnail via direct multipart POST.
if (thumbnailFile) {
try {
await put(`/api/items/${encodeURIComponent(pn)}/thumbnail`, {
object_key: thumbnailFile.objectKey,
});
await upload(
thumbnailFile.file,
`/api/items/${encodedPN}/thumbnail/upload`,
);
} catch {
// Thumbnail failure is non-blocking.
}
@@ -392,21 +349,12 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
backgroundColor: "var(--ctp-mantle)",
}}
>
{thumbnailFile?.uploadStatus === "complete" ? (
{thumbnailFile ? (
<img
src={URL.createObjectURL(thumbnailFile.file)}
alt="Thumbnail preview"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : thumbnailFile?.uploadStatus === "uploading" ? (
<span
style={{
fontSize: "var(--font-table)",
color: "var(--ctp-subtext0)",
}}
>
Uploading... {thumbnailFile.uploadProgress}%
</span>
) : (
<span
style={{
@@ -414,7 +362,7 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
color: "var(--ctp-subtext0)",
}}
>
Click to upload
Click to select
</span>
)}
</div>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { X } from "lucide-react";
import { X, Pencil, Trash2 } from "lucide-react";
import { get } from "../../api/client";
import type { Item } from "../../api/types";
import { MainTab } from "./MainTab";
@@ -114,22 +114,6 @@ export function ItemDetail({
{item.item_type}
</span>
<span style={{ flex: 1 }} />
{isEditor && (
<>
<button
onClick={() => onEdit(item.part_number)}
style={headerBtnStyle}
>
Edit
</button>
<button
onClick={() => onDelete(item.part_number)}
style={{ ...headerBtnStyle, color: "var(--ctp-red)" }}
>
Delete
</button>
</>
)}
<button
onClick={onClose}
style={{
@@ -142,11 +126,11 @@ export function ItemDetail({
</button>
</div>
{/* Tabs */}
{/* Tabs + actions */}
<div
style={{
display: "flex",
gap: "0",
alignItems: "center",
borderBottom: "1px solid var(--ctp-surface1)",
backgroundColor: "var(--ctp-mantle)",
flexShrink: 0,
@@ -175,6 +159,33 @@ export function ItemDetail({
{tab.label}
</button>
))}
<span style={{ flex: 1 }} />
{isEditor && (
<div
style={{ display: "flex", gap: "0.25rem", paddingRight: "0.5rem" }}
>
<button
onClick={() => onEdit(item.part_number)}
style={{
...tabActionBtnStyle,
color: "var(--ctp-subtext1)",
}}
title="Edit item"
>
<Pencil size={13} /> Edit
</button>
<button
onClick={() => onDelete(item.part_number)}
style={{
...tabActionBtnStyle,
color: "var(--ctp-red)",
}}
title="Delete item"
>
<Trash2 size={13} /> Delete
</button>
</div>
)}
</div>
{/* Tab Content */}
@@ -207,3 +218,15 @@ const headerBtnStyle: React.CSSProperties = {
fontSize: "var(--font-table)",
padding: "0.25rem 0.5rem",
};
const tabActionBtnStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
background: "none",
border: "none",
cursor: "pointer",
fontSize: "var(--font-table)",
padding: "0.25rem 0.5rem",
borderRadius: "0.25rem",
};

Some files were not shown because too many files have changed in this diff Show More