232 lines
6.6 KiB
Go
232 lines
6.6 KiB
Go
package runtimecodex
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"inbox/internal/app/runtimeconfig"
|
|
"inbox/internal/domain/role"
|
|
)
|
|
|
|
const (
|
|
WorkspaceDir = ".inbox/runtime-codex"
|
|
ContainerDir = "/workspace/.inbox/runtime-codex"
|
|
ContainerHome = "/root"
|
|
ContainerCodex = "/root/.codex"
|
|
configFilename = "config.toml"
|
|
authFilename = "auth.json"
|
|
)
|
|
|
|
type RoleCatalog interface {
|
|
ListRoles(ctx context.Context) ([]role.Definition, error)
|
|
}
|
|
|
|
type RoleResolver interface {
|
|
ResolveRole(ctx context.Context, workspaceID, roleName string) (runtimeconfig.ResolvedRole, error)
|
|
}
|
|
|
|
type Materializer struct {
|
|
roles RoleCatalog
|
|
resolver RoleResolver
|
|
}
|
|
|
|
func NewMaterializer(roles RoleCatalog, resolver RoleResolver) *Materializer {
|
|
return &Materializer{
|
|
roles: roles,
|
|
resolver: resolver,
|
|
}
|
|
}
|
|
|
|
func (m *Materializer) Sync(ctx context.Context, workspaceID, workspaceRoot string) error {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(workspaceRoot) == "" {
|
|
return fmt.Errorf("workspace root is required")
|
|
}
|
|
items, err := m.roles.ListRoles(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("list roles for runtime codex config: %w", err)
|
|
}
|
|
|
|
baseDir := filepath.Join(workspaceRoot, ".inbox")
|
|
liveDir := filepath.Join(workspaceRoot, filepath.FromSlash(WorkspaceDir))
|
|
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
|
return fmt.Errorf("ensure runtime codex base dir: %w", err)
|
|
}
|
|
|
|
tmpDir, err := os.MkdirTemp(baseDir, "runtime-codex-")
|
|
if err != nil {
|
|
return fmt.Errorf("create runtime codex temp dir: %w", err)
|
|
}
|
|
cleanup := func() { _ = os.RemoveAll(tmpDir) }
|
|
|
|
for _, item := range items {
|
|
item = role.NormalizeDefinition(item)
|
|
if !item.IsEnabled || item.ExecutorKind != role.ExecutorKindCodex {
|
|
continue
|
|
}
|
|
resolved, err := m.resolver.ResolveRole(ctx, workspaceID, item.Name)
|
|
if err != nil {
|
|
cleanup()
|
|
return fmt.Errorf("resolve role %q for runtime codex config: %w", item.Name, err)
|
|
}
|
|
if _, err := WriteResolvedRoleHome(tmpDir, resolved); err != nil {
|
|
cleanup()
|
|
return fmt.Errorf("write runtime codex home for %q: %w", item.Name, err)
|
|
}
|
|
if err := writeResolvedRoleSkills(tmpDir, resolved); err != nil {
|
|
cleanup()
|
|
return fmt.Errorf("write runtime codex skills for %q: %w", item.Name, err)
|
|
}
|
|
}
|
|
|
|
_ = os.RemoveAll(liveDir)
|
|
if err := os.Rename(tmpDir, liveDir); err != nil {
|
|
cleanup()
|
|
return fmt.Errorf("publish runtime codex dir: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ContainerHomeDir(roleName string) string {
|
|
return path.Join(ContainerDir, sanitizeRolePath(roleName))
|
|
}
|
|
|
|
func ContainerUserHomeDir() string {
|
|
return ContainerHome
|
|
}
|
|
|
|
func ContainerCodexDir() string {
|
|
return ContainerCodex
|
|
}
|
|
|
|
func WorkspaceHomeDir(workspaceRoot, roleName string) string {
|
|
return filepath.Join(workspaceRoot, filepath.FromSlash(WorkspaceDir), sanitizeRolePath(roleName))
|
|
}
|
|
|
|
func WorkspaceCodexDir(workspaceRoot, roleName string) string {
|
|
return filepath.Join(WorkspaceHomeDir(workspaceRoot, roleName), ".codex")
|
|
}
|
|
|
|
func WriteResolvedRoleHome(root string, resolved runtimeconfig.ResolvedRole) (string, error) {
|
|
roleHome := filepath.Join(root, sanitizeRolePath(resolved.Role.Name))
|
|
codexDir := filepath.Join(roleHome, ".codex")
|
|
if err := os.MkdirAll(codexDir, 0755); err != nil {
|
|
return "", fmt.Errorf("create runtime codex dir for %q: %w", resolved.Role.Name, err)
|
|
}
|
|
|
|
configText := normalizeConfigTOML(resolved.Config.ConfigTOML)
|
|
if err := os.WriteFile(filepath.Join(codexDir, configFilename), []byte(configText), 0644); err != nil {
|
|
return "", fmt.Errorf("write config.toml for %q: %w", resolved.Role.Name, err)
|
|
}
|
|
|
|
authBytes, err := normalizeAuthJSON(resolved.Config.AuthJSON)
|
|
if err != nil {
|
|
return "", fmt.Errorf("normalize auth.json for %q: %w", resolved.Role.Name, err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(codexDir, authFilename), authBytes, 0600); err != nil {
|
|
return "", fmt.Errorf("write auth.json for %q: %w", resolved.Role.Name, err)
|
|
}
|
|
return roleHome, nil
|
|
}
|
|
|
|
func HostContainerRuntimeRoot(projectRoot, workspaceID string) string {
|
|
return filepath.Join(projectRoot, ".runtime", "container-codex", sanitizeRolePath(workspaceID))
|
|
}
|
|
|
|
func HostContainerCodexDir(projectRoot, workspaceID, roleName string) string {
|
|
return WorkspaceCodexDir(HostContainerRuntimeRoot(projectRoot, workspaceID), roleName)
|
|
}
|
|
|
|
func HostLeaderRuntimeRoot(projectRoot, workspaceID string) string {
|
|
return filepath.Join(projectRoot, ".runtime", "leader-codex", sanitizeRolePath(workspaceID))
|
|
}
|
|
|
|
func HostLeaderHomeDir(projectRoot, workspaceID, roleName string) string {
|
|
return filepath.Join(HostLeaderRuntimeRoot(projectRoot, workspaceID), sanitizeRolePath(roleName))
|
|
}
|
|
|
|
func HostLeaderCodexDir(projectRoot, workspaceID, roleName string) string {
|
|
return filepath.Join(HostLeaderHomeDir(projectRoot, workspaceID, roleName), ".codex")
|
|
}
|
|
|
|
func sanitizeRolePath(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return "role"
|
|
}
|
|
replacer := strings.NewReplacer(
|
|
"/", "-",
|
|
"\\", "-",
|
|
":", "-",
|
|
"*", "-",
|
|
"?", "-",
|
|
"\"", "-",
|
|
"<", "-",
|
|
">", "-",
|
|
"|", "-",
|
|
" ", "-",
|
|
)
|
|
value = replacer.Replace(value)
|
|
value = strings.Trim(value, ".-")
|
|
if value == "" {
|
|
return "role"
|
|
}
|
|
return value
|
|
}
|
|
|
|
func normalizeConfigTOML(value string) string {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
|
|
func normalizeAuthJSON(value string) ([]byte, error) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
value = "{}"
|
|
}
|
|
if !json.Valid([]byte(value)) {
|
|
return nil, fmt.Errorf("invalid json")
|
|
}
|
|
|
|
var decoded any
|
|
if err := json.Unmarshal([]byte(value), &decoded); err != nil {
|
|
return nil, err
|
|
}
|
|
return json.MarshalIndent(decoded, "", " ")
|
|
}
|
|
|
|
func writeResolvedRoleSkills(root string, resolved runtimeconfig.ResolvedRole) error {
|
|
if len(resolved.Skills) == 0 {
|
|
return nil
|
|
}
|
|
codexDir := filepath.Join(root, sanitizeRolePath(resolved.Role.Name), ".codex")
|
|
skillsRoot := filepath.Join(codexDir, "skills")
|
|
for _, item := range resolved.Skills {
|
|
skillKey := strings.TrimSpace(item.Skill.SkillKey)
|
|
if skillKey == "" {
|
|
skillKey = sanitizeRolePath(item.Skill.ID)
|
|
}
|
|
if skillKey == "" {
|
|
return fmt.Errorf("resolved skill key is required")
|
|
}
|
|
targetDir := filepath.Join(skillsRoot, sanitizeRolePath(skillKey))
|
|
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
|
return fmt.Errorf("create skill dir %s: %w", targetDir, err)
|
|
}
|
|
skillBody := strings.TrimSpace(item.Skill.ContentMarkdown)
|
|
if skillBody == "" {
|
|
return fmt.Errorf("skill %q content_markdown is required", skillKey)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(targetDir, "SKILL.md"), []byte(skillBody), 0644); err != nil {
|
|
return fmt.Errorf("write SKILL.md for %q: %w", skillKey, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|