mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
467 lines
12 KiB
Go
467 lines
12 KiB
Go
package workspace
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"path"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"github.com/memohai/memoh/internal/config"
|
|
"github.com/memohai/memoh/internal/db"
|
|
dbsqlc "github.com/memohai/memoh/internal/db/sqlc"
|
|
)
|
|
|
|
const (
|
|
workspaceMetadataKey = "workspace"
|
|
workspaceImageMetadataKey = "image"
|
|
workspaceGPUMetadataKey = "gpu"
|
|
workspaceGPUDevicesKey = "devices"
|
|
workspaceSkillDiscoveryRootsMetadataKey = "skill_discovery_roots"
|
|
)
|
|
|
|
type WorkspaceGPUConfig struct {
|
|
Devices []string `json:"devices,omitempty"`
|
|
}
|
|
|
|
func decodeBotMetadata(payload []byte) (map[string]any, error) {
|
|
if len(payload) == 0 {
|
|
return map[string]any{}, nil
|
|
}
|
|
var data map[string]any
|
|
if err := json.Unmarshal(payload, &data); err != nil {
|
|
return nil, err
|
|
}
|
|
if data == nil {
|
|
data = map[string]any{}
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func cloneAnyMap(src map[string]any) map[string]any {
|
|
if src == nil {
|
|
return map[string]any{}
|
|
}
|
|
cloned := make(map[string]any, len(src))
|
|
for key, value := range src {
|
|
cloned[key] = value
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func workspaceSection(metadata map[string]any) map[string]any {
|
|
raw, ok := metadata[workspaceMetadataKey]
|
|
if !ok {
|
|
return map[string]any{}
|
|
}
|
|
section, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return map[string]any{}
|
|
}
|
|
return cloneAnyMap(section)
|
|
}
|
|
|
|
func workspaceImageFromMetadata(metadata map[string]any) string {
|
|
section := workspaceSection(metadata)
|
|
image, _ := section[workspaceImageMetadataKey].(string)
|
|
return strings.TrimSpace(image)
|
|
}
|
|
|
|
func normalizeWorkspaceGPUDevices(devices []string) []string {
|
|
if len(devices) == 0 {
|
|
return nil
|
|
}
|
|
|
|
seen := make(map[string]struct{}, len(devices))
|
|
normalized := make([]string, 0, len(devices))
|
|
for _, raw := range devices {
|
|
device := strings.TrimSpace(raw)
|
|
if device == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[device]; ok {
|
|
continue
|
|
}
|
|
seen[device] = struct{}{}
|
|
normalized = append(normalized, device)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func workspaceGPUFromMetadata(metadata map[string]any) (WorkspaceGPUConfig, bool) {
|
|
section := workspaceSection(metadata)
|
|
raw, ok := section[workspaceGPUMetadataKey]
|
|
if !ok {
|
|
return WorkspaceGPUConfig{}, false
|
|
}
|
|
|
|
gpuSection, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return WorkspaceGPUConfig{}, true
|
|
}
|
|
|
|
var devices []string
|
|
switch typed := gpuSection[workspaceGPUDevicesKey].(type) {
|
|
case []string:
|
|
devices = append(devices, typed...)
|
|
case []any:
|
|
for _, item := range typed {
|
|
if device, ok := item.(string); ok {
|
|
devices = append(devices, device)
|
|
}
|
|
}
|
|
}
|
|
|
|
return WorkspaceGPUConfig{Devices: normalizeWorkspaceGPUDevices(devices)}, true
|
|
}
|
|
|
|
func workspaceSkillDiscoveryRootsFromMetadata(metadata map[string]any) ([]string, bool) {
|
|
section := workspaceSection(metadata)
|
|
raw, ok := section[workspaceSkillDiscoveryRootsMetadataKey]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
|
|
var roots []string
|
|
switch typed := raw.(type) {
|
|
case []string:
|
|
roots = append(roots, typed...)
|
|
case []any:
|
|
for _, item := range typed {
|
|
if root, ok := item.(string); ok {
|
|
roots = append(roots, root)
|
|
}
|
|
}
|
|
default:
|
|
return []string{}, true
|
|
}
|
|
|
|
normalized := normalizeWorkspaceSkillDiscoveryRoots(roots)
|
|
if normalized == nil {
|
|
return []string{}, true
|
|
}
|
|
return normalized, true
|
|
}
|
|
|
|
func withWorkspaceImagePreference(metadata map[string]any, image string) map[string]any {
|
|
next := cloneAnyMap(metadata)
|
|
section := workspaceSection(next)
|
|
section[workspaceImageMetadataKey] = strings.TrimSpace(image)
|
|
next[workspaceMetadataKey] = section
|
|
return next
|
|
}
|
|
|
|
func withoutWorkspaceImagePreference(metadata map[string]any) map[string]any {
|
|
next := cloneAnyMap(metadata)
|
|
section := workspaceSection(next)
|
|
delete(section, workspaceImageMetadataKey)
|
|
if len(section) == 0 {
|
|
delete(next, workspaceMetadataKey)
|
|
return next
|
|
}
|
|
next[workspaceMetadataKey] = section
|
|
return next
|
|
}
|
|
|
|
func withWorkspaceGPUPreference(metadata map[string]any, gpu WorkspaceGPUConfig) map[string]any {
|
|
next := cloneAnyMap(metadata)
|
|
section := workspaceSection(next)
|
|
section[workspaceGPUMetadataKey] = map[string]any{
|
|
workspaceGPUDevicesKey: normalizeWorkspaceGPUDevices(gpu.Devices),
|
|
}
|
|
next[workspaceMetadataKey] = section
|
|
return next
|
|
}
|
|
|
|
//nolint:unused // Kept for tests and upcoming metadata plumbing.
|
|
func withWorkspaceSkillDiscoveryRoots(metadata map[string]any, roots []string) map[string]any {
|
|
next := cloneAnyMap(metadata)
|
|
section := workspaceSection(next)
|
|
normalized := normalizeWorkspaceSkillDiscoveryRoots(roots)
|
|
if normalized == nil {
|
|
normalized = []string{}
|
|
}
|
|
section[workspaceSkillDiscoveryRootsMetadataKey] = normalized
|
|
next[workspaceMetadataKey] = section
|
|
return next
|
|
}
|
|
|
|
func withoutWorkspaceGPUPreference(metadata map[string]any) map[string]any {
|
|
next := cloneAnyMap(metadata)
|
|
section := workspaceSection(next)
|
|
delete(section, workspaceGPUMetadataKey)
|
|
if len(section) == 0 {
|
|
delete(next, workspaceMetadataKey)
|
|
return next
|
|
}
|
|
next[workspaceMetadataKey] = section
|
|
return next
|
|
}
|
|
|
|
//nolint:unused // Kept for tests and upcoming metadata plumbing.
|
|
func withoutWorkspaceSkillDiscoveryRoots(metadata map[string]any) map[string]any {
|
|
next := cloneAnyMap(metadata)
|
|
section := workspaceSection(next)
|
|
delete(section, workspaceSkillDiscoveryRootsMetadataKey)
|
|
if len(section) == 0 {
|
|
delete(next, workspaceMetadataKey)
|
|
return next
|
|
}
|
|
next[workspaceMetadataKey] = section
|
|
return next
|
|
}
|
|
|
|
func (m *Manager) botWorkspaceImagePreference(ctx context.Context, botID string) (string, error) {
|
|
if m.db == nil || m.queries == nil {
|
|
return "", nil
|
|
}
|
|
botUUID, err := db.ParseUUID(botID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
row, err := m.queries.GetBotByID(ctx, botUUID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", nil
|
|
}
|
|
return "", err
|
|
}
|
|
metadata, err := decodeBotMetadata(row.Metadata)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return workspaceImageFromMetadata(metadata), nil
|
|
}
|
|
|
|
func (m *Manager) updateBotWorkspaceImagePreference(ctx context.Context, botID, image string, clearPreference bool) error {
|
|
if m.db == nil || m.queries == nil {
|
|
return nil
|
|
}
|
|
botUUID, err := db.ParseUUID(botID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
row, err := m.queries.GetBotByID(ctx, botUUID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
metadata, err := decodeBotMetadata(row.Metadata)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if clearPreference {
|
|
metadata = withoutWorkspaceImagePreference(metadata)
|
|
} else {
|
|
metadata = withWorkspaceImagePreference(metadata, image)
|
|
}
|
|
payload, err := json.Marshal(metadata)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = m.queries.UpdateBotProfile(ctx, dbsqlc.UpdateBotProfileParams{
|
|
ID: botUUID,
|
|
DisplayName: row.DisplayName,
|
|
AvatarUrl: row.AvatarUrl,
|
|
Timezone: row.Timezone,
|
|
IsActive: row.IsActive,
|
|
Metadata: payload,
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (m *Manager) RememberWorkspaceImage(ctx context.Context, botID, image string) error {
|
|
return m.updateBotWorkspaceImagePreference(ctx, botID, config.NormalizeImageRef(image), false)
|
|
}
|
|
|
|
func (m *Manager) ClearWorkspaceImagePreference(ctx context.Context, botID string) error {
|
|
return m.updateBotWorkspaceImagePreference(ctx, botID, "", true)
|
|
}
|
|
|
|
func (m *Manager) botWorkspaceGPUPreference(ctx context.Context, botID string) (WorkspaceGPUConfig, bool, error) {
|
|
if m.db == nil || m.queries == nil {
|
|
return WorkspaceGPUConfig{}, false, nil
|
|
}
|
|
botUUID, err := db.ParseUUID(botID)
|
|
if err != nil {
|
|
return WorkspaceGPUConfig{}, false, err
|
|
}
|
|
row, err := m.queries.GetBotByID(ctx, botUUID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return WorkspaceGPUConfig{}, false, nil
|
|
}
|
|
return WorkspaceGPUConfig{}, false, err
|
|
}
|
|
metadata, err := decodeBotMetadata(row.Metadata)
|
|
if err != nil {
|
|
return WorkspaceGPUConfig{}, false, err
|
|
}
|
|
gpu, ok := workspaceGPUFromMetadata(metadata)
|
|
return gpu, ok, nil
|
|
}
|
|
|
|
func (m *Manager) botWorkspaceSkillDiscoveryRootsPreference(ctx context.Context, botID string) ([]string, bool, error) {
|
|
if m.db == nil || m.queries == nil {
|
|
return nil, false, nil
|
|
}
|
|
botUUID, err := db.ParseUUID(botID)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
row, err := m.queries.GetBotByID(ctx, botUUID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, false, nil
|
|
}
|
|
return nil, false, err
|
|
}
|
|
metadata, err := decodeBotMetadata(row.Metadata)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
roots, ok := workspaceSkillDiscoveryRootsFromMetadata(metadata)
|
|
return roots, ok, nil
|
|
}
|
|
|
|
func (m *Manager) updateBotWorkspaceGPUPreference(ctx context.Context, botID string, gpu WorkspaceGPUConfig, clearPreference bool) error {
|
|
if m.db == nil || m.queries == nil {
|
|
return nil
|
|
}
|
|
botUUID, err := db.ParseUUID(botID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
row, err := m.queries.GetBotByID(ctx, botUUID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
metadata, err := decodeBotMetadata(row.Metadata)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if clearPreference {
|
|
metadata = withoutWorkspaceGPUPreference(metadata)
|
|
} else {
|
|
metadata = withWorkspaceGPUPreference(metadata, gpu)
|
|
}
|
|
payload, err := json.Marshal(metadata)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = m.queries.UpdateBotProfile(ctx, dbsqlc.UpdateBotProfileParams{
|
|
ID: botUUID,
|
|
DisplayName: row.DisplayName,
|
|
AvatarUrl: row.AvatarUrl,
|
|
Timezone: row.Timezone,
|
|
IsActive: row.IsActive,
|
|
Metadata: payload,
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (m *Manager) RememberWorkspaceGPU(ctx context.Context, botID string, gpu WorkspaceGPUConfig) error {
|
|
gpu.Devices = normalizeWorkspaceGPUDevices(gpu.Devices)
|
|
return m.updateBotWorkspaceGPUPreference(ctx, botID, gpu, false)
|
|
}
|
|
|
|
func (m *Manager) ClearWorkspaceGPUPreference(ctx context.Context, botID string) error {
|
|
return m.updateBotWorkspaceGPUPreference(ctx, botID, WorkspaceGPUConfig{}, true)
|
|
}
|
|
|
|
func (m *Manager) ResolveWorkspaceImage(ctx context.Context, botID string) (string, error) {
|
|
return m.resolveWorkspaceImage(ctx, botID)
|
|
}
|
|
|
|
func (m *Manager) ResolveWorkspaceGPU(ctx context.Context, botID string) (WorkspaceGPUConfig, error) {
|
|
return m.resolveWorkspaceGPU(ctx, botID)
|
|
}
|
|
|
|
func (m *Manager) ResolveWorkspaceSkillDiscoveryRoots(ctx context.Context, botID string) ([]string, error) {
|
|
return m.resolveWorkspaceSkillDiscoveryRoots(ctx, botID)
|
|
}
|
|
|
|
func SkillDiscoveryRootsFromMetadata(metadata map[string]any) []string {
|
|
roots, ok := workspaceSkillDiscoveryRootsFromMetadata(metadata)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return roots
|
|
}
|
|
|
|
func (m *Manager) resolveWorkspaceImage(ctx context.Context, botID string) (string, error) {
|
|
if m.db != nil && m.queries != nil {
|
|
pgBotID, err := db.ParseUUID(botID)
|
|
if err == nil {
|
|
row, dbErr := m.queries.GetContainerByBotID(ctx, pgBotID)
|
|
if dbErr == nil && strings.TrimSpace(row.Image) != "" {
|
|
return config.NormalizeImageRef(strings.TrimSpace(row.Image)), nil
|
|
}
|
|
if dbErr != nil && !errors.Is(dbErr, pgx.ErrNoRows) {
|
|
return "", dbErr
|
|
}
|
|
}
|
|
}
|
|
|
|
preferredImage, err := m.botWorkspaceImagePreference(ctx, botID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if preferredImage != "" {
|
|
return config.NormalizeImageRef(preferredImage), nil
|
|
}
|
|
|
|
return m.imageRef(), nil
|
|
}
|
|
|
|
func (m *Manager) resolveWorkspaceGPU(ctx context.Context, botID string) (WorkspaceGPUConfig, error) {
|
|
preferredGPU, hasPreference, err := m.botWorkspaceGPUPreference(ctx, botID)
|
|
if err != nil {
|
|
return WorkspaceGPUConfig{}, err
|
|
}
|
|
if hasPreference {
|
|
preferredGPU.Devices = normalizeWorkspaceGPUDevices(preferredGPU.Devices)
|
|
return preferredGPU, nil
|
|
}
|
|
|
|
return WorkspaceGPUConfig{}, nil
|
|
}
|
|
|
|
func (m *Manager) resolveWorkspaceSkillDiscoveryRoots(ctx context.Context, botID string) ([]string, error) {
|
|
roots, hasPreference, err := m.botWorkspaceSkillDiscoveryRootsPreference(ctx, botID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !hasPreference {
|
|
return nil, nil
|
|
}
|
|
return roots, nil
|
|
}
|
|
|
|
func normalizeWorkspaceSkillDiscoveryRoots(roots []string) []string {
|
|
if len(roots) == 0 {
|
|
return nil
|
|
}
|
|
|
|
managedDir := path.Join(config.DefaultDataMount, "skills")
|
|
legacyDir := path.Join(config.DefaultDataMount, ".skills")
|
|
seen := make(map[string]struct{}, len(roots))
|
|
normalized := make([]string, 0, len(roots))
|
|
for _, raw := range roots {
|
|
root := path.Clean(strings.TrimSpace(raw))
|
|
if root == "" || !strings.HasPrefix(root, "/") {
|
|
continue
|
|
}
|
|
if root == managedDir || root == legacyDir {
|
|
continue
|
|
}
|
|
if _, ok := seen[root]; ok {
|
|
continue
|
|
}
|
|
seen[root] = struct{}{}
|
|
normalized = append(normalized, root)
|
|
}
|
|
return normalized
|
|
}
|