Files

263 lines
8.3 KiB
Go

package workspaceruntime
import (
"context"
"path/filepath"
"strings"
"inbox/internal/app/lanegit"
"inbox/internal/app/runtimecodex"
"inbox/internal/app/runtimeconfig"
"inbox/internal/base/slug"
"inbox/internal/base/timeutil"
"inbox/internal/domain/lane"
"inbox/internal/domain/role"
"inbox/internal/domain/skill"
"inbox/internal/domain/workspace"
)
type store interface {
GetProject(context.Context, string) (workspace.Project, error)
UpdateProjectDefaultBranch(context.Context, string, string) error
GetWorkspace(context.Context, string) (workspace.Workspace, error)
UpdateWorkspace(context.Context, workspace.Workspace) error
GetLane(context.Context, string) (lane.Record, error)
UpdateLane(context.Context, lane.Record) (lane.Record, error)
}
type runtimeCodexStore interface {
store
ListRoles(context.Context) ([]role.Definition, error)
GetRole(context.Context, string) (role.Definition, error)
GetRoleConfig(context.Context, string) (role.Config, error)
ListRolePrompts(context.Context, string) ([]role.Prompt, error)
ListRoleSkillBindings(context.Context, string) ([]role.SkillBinding, error)
ListSkillsByIDs(context.Context, []string) (map[string]skill.Definition, error)
}
type Service struct {
store store
projectRoot string
clock timeutil.Clock
workspacesDir string
git *gitWorktreeManager
worker *hostLaneWorkerBinary
inbox *hostInboxBinary
codexHomes *runtimecodex.Materializer
runtime *containerRuntime
}
type Runtime struct {
ContainerName string `json:"container_name"`
ContainerState string `json:"container_state"`
WorktreePath string `json:"worktree_path"`
RunnerEndpoint string `json:"runner_endpoint"`
Ensured bool `json:"ensured"`
}
func NewService(store store, projectRoot, workspacesDir string, serverPort int, clock timeutil.Clock, runner lanegit.Runner) *Service {
if clock == nil {
clock = timeutil.SystemClock{}
}
if runner == nil {
runner = lanegit.ExecRunner{}
}
root := strings.TrimSpace(projectRoot)
probe := newEndpointProbe()
var codexHomes *runtimecodex.Materializer
if configStore, ok := store.(runtimeCodexStore); ok {
codexHomes = runtimecodex.NewMaterializer(
configStore,
runtimeconfig.NewService(configStore, configStore, clock),
)
}
return &Service{
store: store,
projectRoot: root,
clock: clock,
workspacesDir: strings.TrimSpace(workspacesDir),
git: &gitWorktreeManager{projectRoot: root, runner: runner},
worker: &hostLaneWorkerBinary{projectRoot: root, runner: runner},
inbox: &hostInboxBinary{projectRoot: root, runner: runner},
codexHomes: codexHomes,
runtime: &containerRuntime{projectRoot: root, serverPort: serverPort, runner: runner, probe: probe},
}
}
func (s *Service) EnsureRepository(ctx context.Context, projectDir string) (string, error) {
return s.git.ensureRepository(ctx, projectDir)
}
func (s *Service) Ensure(ctx context.Context, workspaceID string) (workspace.Workspace, Runtime, error) {
ws, err := s.store.GetWorkspace(ctx, strings.TrimSpace(workspaceID))
if err != nil {
return workspace.Workspace{}, Runtime{}, err
}
project, err := s.store.GetProject(ctx, ws.ProjectID)
if err != nil {
return workspace.Workspace{}, Runtime{}, err
}
baseBranch, err := s.git.ensureRepository(ctx, project.RootPath)
if err != nil {
_, _ = s.failWorkspace(ctx, ws, err)
return ws, Runtime{}, err
}
if project.DefaultBranch != baseBranch {
if err := s.store.UpdateProjectDefaultBranch(ctx, project.ID, baseBranch); err != nil {
return ws, Runtime{}, err
}
project.DefaultBranch = baseBranch
}
if strings.TrimSpace(s.workspacesDir) != "" {
ws.RootPath = filepath.Join(s.workspacesDir, ws.Slug)
}
ws = workspace.ApplyManagedRuntimeConfig(ws, s.workspacesDir, baseBranch)
return s.ensureWorkspaceHost(ctx, project, ws)
}
func (s *Service) ensureWorkspaceHost(ctx context.Context, project workspace.Project, ws workspace.Workspace) (workspace.Workspace, Runtime, error) {
runtime := Runtime{
ContainerName: "",
WorktreePath: ws.RootPath,
}
if err := s.git.ensureWorktree(ctx, project.RootPath, ws.RootPath, ws.BaseBranch, ws.WorktreeBranch); err != nil {
failed, _ := s.failWorkspace(ctx, ws, err)
return failed, runtime, err
}
now := timeutil.FormatRFC3339(s.clock.Now())
ws.RootPath = filepath.Clean(ws.RootPath)
ws.RuntimeBackend = "host"
ws.ProvisionState = "ready"
ws.ProvisionError = ""
ws.LastProvisionedAt = now
ws.ContainerState = ""
if err := s.store.UpdateWorkspace(ctx, ws); err != nil {
return ws, runtime, err
}
runtime.ContainerState = ""
runtime.RunnerEndpoint = ""
runtime.Ensured = true
return ws, runtime, nil
}
func (s *Service) failWorkspace(ctx context.Context, ws workspace.Workspace, cause error) (workspace.Workspace, error) {
ws.ProvisionState = "failed"
ws.ProvisionError = strings.TrimSpace(cause.Error())
ws.ContainerState = "missing"
if err := s.store.UpdateWorkspace(ctx, ws); err != nil {
return ws, err
}
return ws, cause
}
func (s *Service) EnsureLane(ctx context.Context, laneID string) (lane.Record, error) {
item, err := s.store.GetLane(ctx, strings.TrimSpace(laneID))
if err != nil {
return lane.Record{}, err
}
ws, err := s.store.GetWorkspace(ctx, item.WorkspaceID)
if err != nil {
return lane.Record{}, err
}
project, err := s.store.GetProject(ctx, ws.ProjectID)
if err != nil {
return lane.Record{}, err
}
baseBranch, err := s.git.ensureRepository(ctx, project.RootPath)
if err != nil {
return lane.Record{}, err
}
if ws, _, err = s.ensureWorkspaceHost(ctx, project, ws); err != nil {
return lane.Record{}, err
}
if item.BaseBranch == "" {
item.BaseBranch = firstNonEmpty(strings.TrimSpace(ws.BaseBranch), baseBranch)
}
if item.Slug == "" {
item.Slug = slug.Normalize(item.Name)
}
if item.BranchName == "" {
item.BranchName = lane.DefaultBranchName(ws.Slug, item.TopicID, item.Slug)
}
if item.WorktreePath == "" {
item.WorktreePath = lane.DefaultWorktreePath(ws.RootPath, ws.Slug, item.TopicID, item.Slug)
}
if item.ContainerName == "" {
item.ContainerName = lane.DefaultContainerName(ws.Slug, item.TopicID, item.Slug)
}
if err := s.git.ensureWorktree(ctx, project.RootPath, item.WorktreePath, item.BaseBranch, item.BranchName); err != nil {
return lane.Record{}, err
}
workerCodexDir := ""
if s.codexHomes != nil {
runtimeRoot := runtimecodex.HostContainerRuntimeRoot(s.projectRoot, ws.ID)
if err := s.codexHomes.Sync(ctx, ws.ID, runtimeRoot); err != nil {
return lane.Record{}, err
}
workerCodexDir = runtimecodex.HostContainerCodexDir(s.projectRoot, ws.ID, "worker")
}
workerBinary, err := s.worker.ensure(ctx)
if err != nil {
return lane.Record{}, err
}
inboxBinary, err := s.inbox.ensure(ctx)
if err != nil {
return lane.Record{}, err
}
if err := s.runtime.ensureRunnerImage(ctx); err != nil {
return lane.Record{}, err
}
endpoint, err := s.runtime.ensureLaneContainer(ctx, ws, item, workerBinary, inboxBinary, workerCodexDir)
if err != nil {
return lane.Record{}, err
}
item.RuntimeEndpoint = endpoint
item.Status = lane.StatusRunning
now := timeutil.FormatRFC3339(s.clock.Now())
if item.StartedAt == "" {
item.StartedAt = now
}
item.UpdatedAt = now
return s.store.UpdateLane(ctx, item)
}
func (s *Service) StopLane(ctx context.Context, laneID string) (lane.Record, error) {
item, err := s.store.GetLane(ctx, strings.TrimSpace(laneID))
if err != nil {
return lane.Record{}, err
}
if item.ContainerName != "" {
if err := s.runtime.stopContainer(ctx, item.ContainerName); err != nil {
return lane.Record{}, err
}
}
item.Status = lane.StatusCancelled
item.CompletedAt = timeutil.FormatRFC3339(s.clock.Now())
item.RuntimeEndpoint = ""
return s.store.UpdateLane(ctx, item)
}
func (s *Service) ReleaseLaneRuntime(ctx context.Context, laneID string) (lane.Record, error) {
item, err := s.store.GetLane(ctx, strings.TrimSpace(laneID))
if err != nil {
return lane.Record{}, err
}
if item.ContainerName != "" {
if err := s.runtime.stopContainer(ctx, item.ContainerName); err != nil {
return lane.Record{}, err
}
}
item.RuntimeEndpoint = ""
item.UpdatedAt = timeutil.FormatRFC3339(s.clock.Now())
return s.store.UpdateLane(ctx, item)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}