263 lines
8.3 KiB
Go
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 ""
|
|
}
|