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 "" }