nomadcode/services/core/internal/projectsync/config.go
toki 74ba7ebd76 feat(core): 워크스페이스 슬롯 저장소를 추가한다
Milestone work item 생성 동기화에서 workspace slot 상태를 DB에 저장하고 available slot을 원자적으로 예약할 수 있어야 한다.

리뷰 루프 산출물을 archive하고, 현재 로드맵 우선순위를 Workbench Provider Slot Composition으로 조정한다.
2026-06-06 19:50:16 +09:00

259 lines
6.6 KiB
Go

package projectsync
import (
"errors"
"fmt"
"path/filepath"
"strings"
"github.com/nomadcode/nomadcode-core/internal/db"
"github.com/nomadcode/nomadcode-core/internal/workitem"
)
var ErrInvalidConfig = errors.New("invalid project sync config")
const (
DefaultSlotIndex SlotIndex = 0
MaxSlotIndex SlotIndex = 999
)
type ProviderProjectTarget struct {
Provider workitem.ProviderID
Tenant string
Project string
}
type Config struct {
Target ProviderProjectTarget
GitRemoteURL string
SourceBranch string
WorkspaceID string
WorkspaceBasePath string
RepoDirName string
}
type SlotIndex int
type SlotState string
const (
SlotStateAvailable SlotState = "available"
SlotStateInUse SlotState = "in_use"
SlotStateDirty SlotState = "dirty"
SlotStateError SlotState = "error"
)
func (t ProviderProjectTarget) Normalize() (ProviderProjectTarget, error) {
target := ProviderProjectTarget{
Provider: workitem.ProviderID(strings.TrimSpace(string(t.Provider))),
Tenant: strings.TrimSpace(t.Tenant),
Project: strings.TrimSpace(t.Project),
}
if target.Provider == "" || target.Tenant == "" || target.Project == "" {
return ProviderProjectTarget{}, ErrInvalidConfig
}
return target, nil
}
func (c Config) Normalize() (Config, error) {
target, err := c.Target.Normalize()
if err != nil {
return Config{}, err
}
basePath, err := normalizePath(c.WorkspaceBasePath)
if err != nil {
return Config{}, err
}
repoDir, err := normalizeRepoDirName(c.RepoDirName)
if err != nil {
return Config{}, err
}
config := Config{
Target: target,
GitRemoteURL: strings.TrimSpace(c.GitRemoteURL),
SourceBranch: strings.TrimSpace(c.SourceBranch),
WorkspaceID: strings.TrimSpace(c.WorkspaceID),
WorkspaceBasePath: basePath,
RepoDirName: repoDir,
}
if config.GitRemoteURL == "" || config.SourceBranch == "" || config.WorkspaceID == "" {
return Config{}, ErrInvalidConfig
}
return config, nil
}
func (c Config) ProjectWorkspaceRoot() (string, error) {
config, err := c.Normalize()
if err != nil {
return "", err
}
return ProjectWorkspaceRoot(config.WorkspaceBasePath, config.RepoDirName)
}
func (c Config) SlotWorkspacePath(slot SlotIndex) (string, error) {
config, err := c.Normalize()
if err != nil {
return "", err
}
return SlotWorkspacePath(config.WorkspaceBasePath, config.RepoDirName, slot)
}
func NewSlotIndex(index int) (SlotIndex, error) {
slot := SlotIndex(index)
if !slot.Valid() {
return 0, ErrInvalidConfig
}
return slot, nil
}
func (s SlotIndex) Valid() bool {
return s >= DefaultSlotIndex && s <= MaxSlotIndex
}
func (s SlotIndex) String() string {
return fmt.Sprintf("%03d", int(s))
}
func (s SlotIndex) PathName() (string, error) {
if !s.Valid() {
return "", ErrInvalidConfig
}
return s.String(), nil
}
func (s SlotState) Valid() bool {
switch s {
case SlotStateAvailable, SlotStateInUse, SlotStateDirty, SlotStateError:
return true
default:
return false
}
}
func (s SlotState) Allocatable() bool {
return s == SlotStateAvailable
}
func ProjectWorkspaceRoot(workspaceBasePath, repoDirName string) (string, error) {
basePath, err := normalizePath(workspaceBasePath)
if err != nil {
return "", err
}
repoDir, err := normalizeRepoDirName(repoDirName)
if err != nil {
return "", err
}
return filepath.Join(basePath, repoDir), nil
}
func SlotWorkspacePath(workspaceBasePath, repoDirName string, slot SlotIndex) (string, error) {
slotName, err := slot.PathName()
if err != nil {
return "", err
}
root, err := ProjectWorkspaceRoot(workspaceBasePath, repoDirName)
if err != nil {
return "", err
}
return filepath.Join(root, slotName), nil
}
func normalizePath(value string) (string, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "", ErrInvalidConfig
}
cleaned := filepath.Clean(trimmed)
if cleaned == "." {
return "", ErrInvalidConfig
}
return cleaned, nil
}
func normalizeRepoDirName(value string) (string, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "", ErrInvalidConfig
}
cleaned := filepath.Clean(trimmed)
if cleaned == "." || cleaned == ".." || filepath.IsAbs(cleaned) || strings.ContainsAny(cleaned, `/\`) {
return "", ErrInvalidConfig
}
return cleaned, nil
}
func (c Config) ToCreateProjectSyncParams() db.CreateProjectSyncSettingParams {
return db.CreateProjectSyncSettingParams{
Provider: string(c.Target.Provider),
Tenant: c.Target.Tenant,
Project: c.Target.Project,
GitRemoteUrl: c.GitRemoteURL,
SourceBranch: c.SourceBranch,
WorkspaceID: c.WorkspaceID,
WorkspaceBasePath: c.WorkspaceBasePath,
RepoDirName: c.RepoDirName,
}
}
// WorkspaceSlot is the typed view of a persisted workspace slot row, keeping
// the slot index and state as projectsync domain types.
type WorkspaceSlot struct {
ID int64
ProjectSyncSettingID int64
Index SlotIndex
State SlotState
Path string
}
// SlotFromDBRecord converts a generated db row into the typed WorkspaceSlot.
func SlotFromDBRecord(r db.WorkspaceSlot) WorkspaceSlot {
return WorkspaceSlot{
ID: r.ID,
ProjectSyncSettingID: r.ProjectSyncSettingID,
Index: SlotIndex(r.SlotIndex),
State: SlotState(r.State),
Path: r.Path,
}
}
// ToUpsertWorkspaceSlotParams builds DB upsert params for a slot, rejecting an
// out-of-range index before it reaches the database.
func ToUpsertWorkspaceSlotParams(projectSyncSettingID int64, index SlotIndex, path string) (db.UpsertWorkspaceSlotParams, error) {
if !index.Valid() {
return db.UpsertWorkspaceSlotParams{}, ErrInvalidConfig
}
return db.UpsertWorkspaceSlotParams{
ProjectSyncSettingID: projectSyncSettingID,
SlotIndex: int32(index),
Path: path,
}, nil
}
// ToUpdateWorkspaceSlotStateParams builds DB params for a slot state
// transition, rejecting unknown states to mirror the DB check constraint.
func ToUpdateWorkspaceSlotStateParams(id int64, state SlotState) (db.UpdateWorkspaceSlotStateParams, error) {
if !state.Valid() {
return db.UpdateWorkspaceSlotStateParams{}, ErrInvalidConfig
}
return db.UpdateWorkspaceSlotStateParams{
ID: id,
State: string(state),
}, nil
}
func ConfigFromDBRecord(r db.ProjectSyncSetting) Config {
return Config{
Target: ProviderProjectTarget{
Provider: workitem.ProviderID(r.Provider),
Tenant: r.Tenant,
Project: r.Project,
},
GitRemoteURL: r.GitRemoteUrl,
SourceBranch: r.SourceBranch,
WorkspaceID: r.WorkspaceID,
WorkspaceBasePath: r.WorkspaceBasePath,
RepoDirName: r.RepoDirName,
}
}