chore(repo): reinitialize repository
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
package workspaceruntime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func commandError(action, output string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
output = strings.TrimSpace(output)
|
||||
if output == "" {
|
||||
return fmt.Errorf("%s: %w", action, err)
|
||||
}
|
||||
return fmt.Errorf("%s: %s: %w", action, output, err)
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package workspaceruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/app/lanegit"
|
||||
"inbox/internal/app/runtimecodex"
|
||||
"inbox/internal/domain/lane"
|
||||
"inbox/internal/domain/workspace"
|
||||
)
|
||||
|
||||
const (
|
||||
runtimeBaseImage = "localhost/ai-workflow-agent-runner:local"
|
||||
runnerContainerPort = "31417/tcp"
|
||||
runnerContainerPortNum = "31417"
|
||||
)
|
||||
|
||||
type podmanInspect struct {
|
||||
ImageName string `json:"ImageName"`
|
||||
Path string `json:"Path"`
|
||||
Args []string `json:"Args"`
|
||||
Config struct {
|
||||
Image string `json:"Image"`
|
||||
User string `json:"User"`
|
||||
Env []string `json:"Env"`
|
||||
WorkingDir string `json:"WorkingDir"`
|
||||
} `json:"Config"`
|
||||
State struct {
|
||||
Running bool `json:"Running"`
|
||||
Status string `json:"Status"`
|
||||
} `json:"State"`
|
||||
NetworkSettings struct {
|
||||
Ports map[string][]struct {
|
||||
HostIP string `json:"HostIp"`
|
||||
HostPort string `json:"HostPort"`
|
||||
} `json:"Ports"`
|
||||
} `json:"NetworkSettings"`
|
||||
Mounts []struct {
|
||||
Source string `json:"Source"`
|
||||
Destination string `json:"Destination"`
|
||||
} `json:"Mounts"`
|
||||
}
|
||||
|
||||
type containerRuntime struct {
|
||||
projectRoot string
|
||||
serverPort int
|
||||
runner lanegit.Runner
|
||||
probe *endpointProbe
|
||||
}
|
||||
|
||||
const laneWorkerContainerPath = "/usr/local/bin/lane-worker"
|
||||
const inboxContainerPath = "/usr/local/bin/inbox"
|
||||
|
||||
func (r *containerRuntime) ensureRunnerImage(ctx context.Context) error {
|
||||
out, err := r.runner.Run(ctx, r.projectRoot, nil, "podman", "image", "exists", runtimeBaseImage)
|
||||
if err != nil {
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return fmt.Errorf("runtime base image is missing: %s", runtimeBaseImage)
|
||||
}
|
||||
return commandError("runtime base image is missing: "+runtimeBaseImage, out, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *containerRuntime) ensureLaneContainer(ctx context.Context, ws workspace.Workspace, item lane.Record, workerBinary, inboxBinary, workerCodexDir string) (string, error) {
|
||||
codexFingerprint, err := runtimeCodexFingerprint(workerCodexDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, found, err := r.inspect(ctx, item.ContainerName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if found && r.laneDrifted(info, ws, item, workerBinary, inboxBinary, codexFingerprint) {
|
||||
if err := r.stopAndRemoveContainer(ctx, item.ContainerName); err != nil {
|
||||
return "", err
|
||||
}
|
||||
found = false
|
||||
}
|
||||
if !found {
|
||||
args := []string{
|
||||
"create",
|
||||
"--name", item.ContainerName,
|
||||
"--user", "root",
|
||||
"--entrypoint", laneWorkerContainerPath,
|
||||
"-p", "127.0.0.1::" + runnerContainerPortNum,
|
||||
"-e", "INBOX_WORKSPACE=/workspace",
|
||||
"-e", "INBOX_WORKSPACE_ID=" + ws.ID,
|
||||
"-e", "INBOX_LANE_ID=" + item.ID,
|
||||
"-e", "INBOX_API_URL=http://host.containers.internal:" + strconv.Itoa(r.serverPort),
|
||||
"-e", "INBOX_RUNTIME_AGENT_ID=" + item.ContainerName,
|
||||
"-e", "HOME=" + runtimecodex.ContainerUserHomeDir(),
|
||||
"-v", filepath.Join(item.WorktreePath) + ":/workspace:z",
|
||||
"-v", workerBinary + ":" + laneWorkerContainerPath + ":z,ro",
|
||||
"-v", inboxBinary + ":" + inboxContainerPath + ":z,ro",
|
||||
"-w", "/workspace",
|
||||
}
|
||||
if codexFingerprint != "" {
|
||||
args = append(args, "-e", "INBOX_RUNTIME_CODEX_SHA="+codexFingerprint)
|
||||
}
|
||||
args = append(args, runtimeBaseImage)
|
||||
out, err := r.runner.Run(ctx, r.projectRoot, nil, "podman", args...)
|
||||
if err != nil {
|
||||
return "", commandError("create lane container "+item.ContainerName, out, err)
|
||||
}
|
||||
if err := r.copyRuntimeCodex(ctx, item.ContainerName, workerCodexDir); err != nil {
|
||||
_ = r.stopAndRemoveContainer(ctx, item.ContainerName)
|
||||
return "", err
|
||||
}
|
||||
info, found, err = r.inspect(ctx, item.ContainerName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !found {
|
||||
return "", fmt.Errorf("container %s was not created", item.ContainerName)
|
||||
}
|
||||
}
|
||||
if !info.State.Running {
|
||||
out, err := r.runner.Run(ctx, r.projectRoot, nil, "podman", "start", item.ContainerName)
|
||||
if err != nil {
|
||||
return "", commandError("start lane container "+item.ContainerName, out, err)
|
||||
}
|
||||
}
|
||||
endpoint, err := r.endpoint(ctx, item.ContainerName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := r.probe.wait(endpoint); err != nil {
|
||||
return endpoint, err
|
||||
}
|
||||
return endpoint, nil
|
||||
}
|
||||
|
||||
func (r *containerRuntime) inspect(ctx context.Context, name string) (podmanInspect, bool, error) {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return podmanInspect{}, false, nil
|
||||
}
|
||||
out, err := r.runner.Run(ctx, r.projectRoot, nil, "podman", "inspect", name)
|
||||
if err != nil {
|
||||
if strings.Contains(out, "no such object") || strings.Contains(out, "no container with name or ID") {
|
||||
return podmanInspect{}, false, nil
|
||||
}
|
||||
return podmanInspect{}, false, fmt.Errorf("inspect container %s: %w", name, err)
|
||||
}
|
||||
var items []podmanInspect
|
||||
if err := json.Unmarshal([]byte(out), &items); err != nil {
|
||||
return podmanInspect{}, false, fmt.Errorf("decode podman inspect %s: %w", name, err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return podmanInspect{}, false, nil
|
||||
}
|
||||
return items[0], true, nil
|
||||
}
|
||||
|
||||
func (r *containerRuntime) laneDrifted(info podmanInspect, ws workspace.Workspace, item lane.Record, workerBinary, inboxBinary, codexFingerprint string) bool {
|
||||
if info.ImageName != runtimeBaseImage && info.Config.Image != runtimeBaseImage {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(info.Config.User) != "root" {
|
||||
return true
|
||||
}
|
||||
if !hasPublishedPort(info, runnerContainerPort) {
|
||||
return true
|
||||
}
|
||||
if info.Config.WorkingDir != "/workspace" {
|
||||
return true
|
||||
}
|
||||
if filepath.Clean(strings.TrimSpace(info.Path)) != laneWorkerContainerPath {
|
||||
return true
|
||||
}
|
||||
mounts := make(map[string]string, len(info.Mounts))
|
||||
for _, mount := range info.Mounts {
|
||||
mounts[filepath.Clean(mount.Destination)] = filepath.Clean(mount.Source)
|
||||
}
|
||||
if _, ok := mounts[filepath.Clean(runtimecodex.ContainerCodexDir())]; ok {
|
||||
return true
|
||||
}
|
||||
expectedMounts := map[string]string{
|
||||
"/workspace": filepath.Clean(item.WorktreePath),
|
||||
laneWorkerContainerPath: filepath.Clean(workerBinary),
|
||||
inboxContainerPath: filepath.Clean(inboxBinary),
|
||||
}
|
||||
for destination, source := range expectedMounts {
|
||||
if filepath.Clean(mounts[destination]) != filepath.Clean(source) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
envs := make(map[string]string)
|
||||
for _, item := range info.Config.Env {
|
||||
key, value, ok := strings.Cut(item, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
envs[key] = value
|
||||
}
|
||||
expectedEnv := map[string]string{
|
||||
"INBOX_WORKSPACE": "/workspace",
|
||||
"INBOX_WORKSPACE_ID": ws.ID,
|
||||
"INBOX_LANE_ID": item.ID,
|
||||
"INBOX_RUNTIME_AGENT_ID": item.ContainerName,
|
||||
"INBOX_API_URL": "http://host.containers.internal:" + strconv.Itoa(r.serverPort),
|
||||
}
|
||||
if codexFingerprint != "" {
|
||||
expectedEnv["INBOX_RUNTIME_CODEX_SHA"] = codexFingerprint
|
||||
} else if _, ok := envs["INBOX_RUNTIME_CODEX_SHA"]; ok {
|
||||
return true
|
||||
}
|
||||
for key, value := range expectedEnv {
|
||||
if envs[key] != value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *containerRuntime) copyRuntimeCodex(ctx context.Context, containerName, workerCodexDir string) error {
|
||||
if strings.TrimSpace(workerCodexDir) == "" {
|
||||
return nil
|
||||
}
|
||||
out, err := r.runner.Run(ctx, r.projectRoot, nil, "podman", "cp", workerCodexDir, containerName+":"+runtimecodex.ContainerUserHomeDir())
|
||||
if err != nil {
|
||||
return commandError("copy runtime codex into "+containerName, out, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runtimeCodexFingerprint(workerCodexDir string) (string, error) {
|
||||
if strings.TrimSpace(workerCodexDir) == "" {
|
||||
return "", nil
|
||||
}
|
||||
names := make([]string, 0, 8)
|
||||
err := filepath.Walk(workerCodexDir, func(path string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(workerCodexDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names = append(names, filepath.ToSlash(rel))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("walk runtime codex dir %s: %w", workerCodexDir, err)
|
||||
}
|
||||
sort.Strings(names)
|
||||
sum := sha256.New()
|
||||
for _, name := range names {
|
||||
body, err := os.ReadFile(filepath.Join(workerCodexDir, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read runtime codex file %s: %w", filepath.Join(workerCodexDir, filepath.FromSlash(name)), err)
|
||||
}
|
||||
_, _ = sum.Write([]byte(name))
|
||||
_, _ = sum.Write([]byte{0})
|
||||
_, _ = sum.Write(body)
|
||||
_, _ = sum.Write([]byte{0})
|
||||
}
|
||||
return fmt.Sprintf("%x", sum.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func hasPublishedPort(info podmanInspect, containerPort string) bool {
|
||||
bindings, ok := info.NetworkSettings.Ports[containerPort]
|
||||
if !ok || len(bindings) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
if strings.TrimSpace(binding.HostPort) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *containerRuntime) stopContainer(ctx context.Context, containerName string) error {
|
||||
info, found, err := r.inspect(ctx, containerName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
if info.State.Running {
|
||||
out, err := r.runner.Run(ctx, r.projectRoot, nil, "podman", "stop", containerName)
|
||||
if err != nil {
|
||||
return commandError("stop container "+containerName, out, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *containerRuntime) stopAndRemoveContainer(ctx context.Context, containerName string) error {
|
||||
out, err := r.runner.Run(ctx, r.projectRoot, nil, "podman", "rm", "-f", containerName)
|
||||
if err != nil {
|
||||
return commandError("remove container "+containerName, out, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *containerRuntime) endpoint(ctx context.Context, containerName string) (string, error) {
|
||||
out, err := r.runner.Run(ctx, r.projectRoot, nil, "podman", "port", containerName, runnerContainerPort)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read container port for %s: %w", containerName, err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
if len(lines) == 0 || strings.TrimSpace(lines[0]) == "" {
|
||||
return "", fmt.Errorf("container %s does not expose %s", containerName, runnerContainerPort)
|
||||
}
|
||||
line := strings.TrimSpace(lines[0])
|
||||
port := line[strings.LastIndex(line, ":")+1:]
|
||||
if _, err := strconv.Atoi(port); err != nil {
|
||||
return "", fmt.Errorf("parse runner port %q", line)
|
||||
}
|
||||
return "http://127.0.0.1:" + port, nil
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package workspaceruntime
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"inbox/internal/domain/lane"
|
||||
"inbox/internal/domain/workspace"
|
||||
)
|
||||
|
||||
func TestDriftedWhenRunnerPortIsMissing(t *testing.T) {
|
||||
ws := workspace.Workspace{
|
||||
ID: "ws_1",
|
||||
RootPath: "/tmp/ws",
|
||||
}
|
||||
item := lane.Record{
|
||||
ID: "chain_1",
|
||||
ContainerName: "lane-demo",
|
||||
WorktreePath: "/tmp/ws-chain",
|
||||
}
|
||||
|
||||
runtime := &containerRuntime{serverPort: 3000}
|
||||
info := validInspect(ws, item, "/tmp/lane-worker", "/tmp/inbox", "codex-sha-1")
|
||||
delete(info.NetworkSettings.Ports, runnerContainerPort)
|
||||
|
||||
if !runtime.laneDrifted(info, ws, item, "/tmp/lane-worker", "/tmp/inbox", "codex-sha-1") {
|
||||
t.Fatalf("expected container without %s mapping to drift", runnerContainerPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriftedWhenRunnerPortIsPublished(t *testing.T) {
|
||||
ws := workspace.Workspace{
|
||||
ID: "ws_1",
|
||||
RootPath: "/tmp/ws",
|
||||
}
|
||||
item := lane.Record{
|
||||
ID: "chain_1",
|
||||
ContainerName: "lane-demo",
|
||||
WorktreePath: "/tmp/ws-chain",
|
||||
}
|
||||
|
||||
runtime := &containerRuntime{serverPort: 3000}
|
||||
info := validInspect(ws, item, "/tmp/lane-worker", "/tmp/inbox", "codex-sha-1")
|
||||
|
||||
if runtime.laneDrifted(info, ws, item, "/tmp/lane-worker", "/tmp/inbox", "codex-sha-1") {
|
||||
t.Fatalf("expected container with %s mapping to remain reusable", runnerContainerPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriftedWhenContainerUserIsNotRoot(t *testing.T) {
|
||||
ws := workspace.Workspace{ID: "ws_1", RootPath: "/tmp/ws"}
|
||||
item := lane.Record{
|
||||
ID: "chain_1",
|
||||
ContainerName: "lane-demo",
|
||||
WorktreePath: "/tmp/ws-chain",
|
||||
}
|
||||
|
||||
runtime := &containerRuntime{serverPort: 3000}
|
||||
info := validInspect(ws, item, "/tmp/lane-worker", "/tmp/inbox", "codex-sha-1")
|
||||
info.Config.User = "runner"
|
||||
|
||||
if !runtime.laneDrifted(info, ws, item, "/tmp/lane-worker", "/tmp/inbox", "codex-sha-1") {
|
||||
t.Fatalf("expected non-root container user to drift")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriftedWhenRuntimeCodexFingerprintChanges(t *testing.T) {
|
||||
ws := workspace.Workspace{ID: "ws_1", RootPath: "/tmp/ws"}
|
||||
item := lane.Record{
|
||||
ID: "chain_1",
|
||||
ContainerName: "lane-demo",
|
||||
WorktreePath: "/tmp/ws-chain",
|
||||
}
|
||||
|
||||
runtime := &containerRuntime{serverPort: 3000}
|
||||
info := validInspect(ws, item, "/tmp/lane-worker", "/tmp/inbox", "codex-sha-1")
|
||||
|
||||
if !runtime.laneDrifted(info, ws, item, "/tmp/lane-worker", "/tmp/inbox", "codex-sha-2") {
|
||||
t.Fatalf("expected runtime codex fingerprint mismatch to drift")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeCodexFingerprint(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "config.toml"), []byte("model = \"gpt-5.3-codex\"\n"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile(config.toml) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "auth.json"), []byte("{\"OPENAI_API_KEY\":\"token\"}\n"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile(auth.json) error = %v", err)
|
||||
}
|
||||
|
||||
sum1, err := runtimeCodexFingerprint(root)
|
||||
if err != nil {
|
||||
t.Fatalf("runtimeCodexFingerprint() error = %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(root, "auth.json"), []byte("{\"OPENAI_API_KEY\":\"changed\"}\n"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile(auth.json) error = %v", err)
|
||||
}
|
||||
sum2, err := runtimeCodexFingerprint(root)
|
||||
if err != nil {
|
||||
t.Fatalf("runtimeCodexFingerprint() error = %v", err)
|
||||
}
|
||||
|
||||
if sum1 == "" || sum2 == "" {
|
||||
t.Fatalf("expected non-empty fingerprints, got %q and %q", sum1, sum2)
|
||||
}
|
||||
if sum1 == sum2 {
|
||||
t.Fatalf("expected fingerprint to change when runtime codex contents change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeCodexFingerprintChangesForNestedSkillFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
skillDir := filepath.Join(root, "skills", "inbox")
|
||||
if err := os.MkdirAll(skillDir, 0755); err != nil {
|
||||
t.Fatalf("MkdirAll(skillDir) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Inbox\n"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile(SKILL.md) error = %v", err)
|
||||
}
|
||||
|
||||
sum1, err := runtimeCodexFingerprint(root)
|
||||
if err != nil {
|
||||
t.Fatalf("runtimeCodexFingerprint() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Inbox\n\nUpdated\n"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile(SKILL.md) error = %v", err)
|
||||
}
|
||||
sum2, err := runtimeCodexFingerprint(root)
|
||||
if err != nil {
|
||||
t.Fatalf("runtimeCodexFingerprint() error = %v", err)
|
||||
}
|
||||
if sum1 == sum2 {
|
||||
t.Fatalf("expected nested skill file change to affect fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
func validInspect(ws workspace.Workspace, item lane.Record, workerBinary, inboxBinary, codexFingerprint string) podmanInspect {
|
||||
var info podmanInspect
|
||||
info.ImageName = runtimeBaseImage
|
||||
info.Path = laneWorkerContainerPath
|
||||
info.Config.Image = runtimeBaseImage
|
||||
info.Config.User = "root"
|
||||
info.Config.WorkingDir = "/workspace"
|
||||
info.Config.Env = []string{
|
||||
"INBOX_WORKSPACE=/workspace",
|
||||
"INBOX_WORKSPACE_ID=" + ws.ID,
|
||||
"INBOX_LANE_ID=" + item.ID,
|
||||
"INBOX_RUNTIME_AGENT_ID=" + item.ContainerName,
|
||||
"INBOX_API_URL=http://host.containers.internal:3000",
|
||||
"HOME=/root",
|
||||
"INBOX_RUNTIME_CODEX_SHA=" + codexFingerprint,
|
||||
}
|
||||
info.NetworkSettings.Ports = map[string][]struct {
|
||||
HostIP string `json:"HostIp"`
|
||||
HostPort string `json:"HostPort"`
|
||||
}{
|
||||
runnerContainerPort: {{
|
||||
HostIP: "127.0.0.1",
|
||||
HostPort: "40123",
|
||||
}},
|
||||
}
|
||||
info.Mounts = []struct {
|
||||
Source string `json:"Source"`
|
||||
Destination string `json:"Destination"`
|
||||
}{
|
||||
{Source: item.WorktreePath, Destination: "/workspace"},
|
||||
{Source: workerBinary, Destination: laneWorkerContainerPath},
|
||||
{Source: inboxBinary, Destination: inboxContainerPath},
|
||||
}
|
||||
return info
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package workspaceruntime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type endpointProbe struct {
|
||||
attempts int
|
||||
dialTimeout time.Duration
|
||||
retryDelay time.Duration
|
||||
}
|
||||
|
||||
func newEndpointProbe() *endpointProbe {
|
||||
return &endpointProbe{
|
||||
attempts: 20,
|
||||
dialTimeout: 500 * time.Millisecond,
|
||||
retryDelay: 250 * time.Millisecond,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *endpointProbe) wait(endpoint string) error {
|
||||
address := strings.TrimPrefix(endpoint, "http://")
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < p.attempts; attempt++ {
|
||||
conn, err := net.DialTimeout("tcp", address, p.dialTimeout)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
time.Sleep(p.retryDelay)
|
||||
}
|
||||
return fmt.Errorf("runner endpoint %s is not reachable: %w", endpoint, lastErr)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package workspaceruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/app/lanegit"
|
||||
)
|
||||
|
||||
type gitWorktree struct {
|
||||
Path string
|
||||
Branch string
|
||||
}
|
||||
|
||||
type gitWorktreeManager struct {
|
||||
projectRoot string
|
||||
runner lanegit.Runner
|
||||
}
|
||||
|
||||
func (g *gitWorktreeManager) ensureRepository(ctx context.Context, projectDir string) (string, error) {
|
||||
isRepo, err := g.isRepository(ctx, projectDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if isRepo {
|
||||
branch, err := g.currentBranch(ctx, projectDir)
|
||||
if err == nil && strings.TrimSpace(branch) != "" {
|
||||
return strings.TrimSpace(branch), nil
|
||||
}
|
||||
return "main", nil
|
||||
}
|
||||
|
||||
out, err := g.runner.Run(ctx, g.projectRoot, nil, "git", "-C", projectDir, "init", "-b", "main")
|
||||
if err != nil {
|
||||
return "", commandError("git init "+projectDir, out, err)
|
||||
}
|
||||
env := map[string]string{
|
||||
"GIT_AUTHOR_NAME": "Inbox",
|
||||
"GIT_AUTHOR_EMAIL": "inbox@local",
|
||||
"GIT_COMMITTER_NAME": "Inbox",
|
||||
"GIT_COMMITTER_EMAIL": "inbox@local",
|
||||
}
|
||||
out, err = g.runner.Run(ctx, g.projectRoot, env, "git", "-C", projectDir, "commit", "--allow-empty", "-m", "Initialize workspace repository")
|
||||
if err != nil {
|
||||
return "", commandError("create initial empty commit", out, err)
|
||||
}
|
||||
return "main", nil
|
||||
}
|
||||
|
||||
func (g *gitWorktreeManager) ensureWorktree(ctx context.Context, projectDir, worktreePath, baseBranch, worktreeBranch string) error {
|
||||
worktreePath = filepath.Clean(worktreePath)
|
||||
entries, err := g.listWorktrees(ctx, projectDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stale, err := hasMissingWorktreePaths(entries); err != nil {
|
||||
return err
|
||||
} else if stale {
|
||||
if err := g.pruneWorktrees(ctx, projectDir); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err = g.listWorktrees(ctx, projectDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if filepath.Clean(entry.Path) == worktreePath {
|
||||
expected := "refs/heads/" + worktreeBranch
|
||||
if strings.TrimSpace(entry.Branch) != "" && entry.Branch != expected {
|
||||
return fmt.Errorf("worktree path %s is attached to %s, want %s", worktreePath, entry.Branch, expected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if entry.Branch == "refs/heads/"+worktreeBranch && filepath.Clean(entry.Path) != worktreePath {
|
||||
return fmt.Errorf("worktree branch %s already attached at %s", worktreeBranch, entry.Path)
|
||||
}
|
||||
}
|
||||
|
||||
if info, err := os.Stat(worktreePath); err == nil && info.IsDir() {
|
||||
return fmt.Errorf("worktree path already exists but is not registered: %s", worktreePath)
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("stat worktree path: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(worktreePath), 0755); err != nil {
|
||||
return fmt.Errorf("create worktree parent: %w", err)
|
||||
}
|
||||
|
||||
branchExists, err := g.branchExists(ctx, projectDir, worktreeBranch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
args := []string{"-C", projectDir, "worktree", "add"}
|
||||
if !branchExists {
|
||||
args = append(args, "-b", worktreeBranch)
|
||||
}
|
||||
args = append(args, worktreePath)
|
||||
if branchExists {
|
||||
args = append(args, worktreeBranch)
|
||||
} else {
|
||||
args = append(args, baseBranch)
|
||||
}
|
||||
out, err := g.runner.Run(ctx, g.projectRoot, nil, "git", args...)
|
||||
if err != nil {
|
||||
return commandError("create worktree "+worktreePath, out, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *gitWorktreeManager) isRepository(ctx context.Context, projectDir string) (bool, error) {
|
||||
out, err := g.runner.Run(ctx, g.projectRoot, nil, "git", "-C", projectDir, "rev-parse", "--is-inside-work-tree")
|
||||
if err != nil {
|
||||
if strings.Contains(out, "not a git repository") {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("detect git repository: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(out) == "true", nil
|
||||
}
|
||||
|
||||
func (g *gitWorktreeManager) currentBranch(ctx context.Context, projectDir string) (string, error) {
|
||||
out, err := g.runner.Run(ctx, g.projectRoot, nil, "git", "-C", projectDir, "symbolic-ref", "--quiet", "--short", "HEAD")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(out), nil
|
||||
}
|
||||
|
||||
func (g *gitWorktreeManager) listWorktrees(ctx context.Context, projectDir string) ([]gitWorktree, error) {
|
||||
out, err := g.runner.Run(ctx, g.projectRoot, nil, "git", "-C", projectDir, "worktree", "list", "--porcelain")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list git worktrees: %w", err)
|
||||
}
|
||||
lines := strings.Split(out, "\n")
|
||||
items := make([]gitWorktree, 0)
|
||||
var current gitWorktree
|
||||
flush := func() {
|
||||
if strings.TrimSpace(current.Path) == "" {
|
||||
return
|
||||
}
|
||||
items = append(items, current)
|
||||
current = gitWorktree{}
|
||||
}
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(line, "worktree "):
|
||||
flush()
|
||||
current.Path = strings.TrimSpace(strings.TrimPrefix(line, "worktree "))
|
||||
case strings.HasPrefix(line, "branch "):
|
||||
current.Branch = strings.TrimSpace(strings.TrimPrefix(line, "branch "))
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (g *gitWorktreeManager) branchExists(ctx context.Context, projectDir, branch string) (bool, error) {
|
||||
out, err := g.runner.Run(ctx, g.projectRoot, nil, "git", "-C", projectDir, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
|
||||
if err != nil {
|
||||
if out == "" {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("check git branch %s: %w", branch, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (g *gitWorktreeManager) pruneWorktrees(ctx context.Context, projectDir string) error {
|
||||
out, err := g.runner.Run(ctx, g.projectRoot, nil, "git", "-C", projectDir, "worktree", "prune", "--expire", "now")
|
||||
if err != nil {
|
||||
return commandError("prune stale worktrees", out, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasMissingWorktreePaths(entries []gitWorktree) (bool, error) {
|
||||
for _, entry := range entries {
|
||||
info, err := os.Stat(filepath.Clean(entry.Path))
|
||||
if err == nil {
|
||||
if !info.IsDir() {
|
||||
return false, fmt.Errorf("worktree path is not a directory: %s", entry.Path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("stat registered worktree path %s: %w", entry.Path, err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package workspaceruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"inbox/internal/app/lanegit"
|
||||
)
|
||||
|
||||
type hostInboxBinary struct {
|
||||
projectRoot string
|
||||
runner lanegit.Runner
|
||||
}
|
||||
|
||||
func (b *hostInboxBinary) ensure(ctx context.Context) (string, error) {
|
||||
binaryPath := filepath.Join(b.projectRoot, ".runtime", "bin", "inbox")
|
||||
needsBuild, err := b.needsBuild(binaryPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !needsBuild {
|
||||
return binaryPath, nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(binaryPath), 0755); err != nil {
|
||||
return "", fmt.Errorf("create runtime bin dir: %w", err)
|
||||
}
|
||||
env := map[string]string{
|
||||
"GOOS": "linux",
|
||||
"GOARCH": runtime.GOARCH,
|
||||
}
|
||||
out, err := b.runner.Run(ctx, filepath.Join(b.projectRoot, "inbox"), env, "go", "build", "-o", binaryPath, "./cmd/inbox")
|
||||
if err != nil {
|
||||
return "", commandError("build inbox binary", out, err)
|
||||
}
|
||||
return binaryPath, nil
|
||||
}
|
||||
|
||||
func (b *hostInboxBinary) needsBuild(binaryPath string) (bool, error) {
|
||||
return sourceTreeNeedsBuild(b.projectRoot, "inbox", binaryPath)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package workspaceruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"inbox/internal/app/lanegit"
|
||||
)
|
||||
|
||||
type hostLaneWorkerBinary struct {
|
||||
projectRoot string
|
||||
runner lanegit.Runner
|
||||
}
|
||||
|
||||
func (b *hostLaneWorkerBinary) ensure(ctx context.Context) (string, error) {
|
||||
binaryPath := filepath.Join(b.projectRoot, ".runtime", "bin", "lane-worker")
|
||||
needsBuild, err := b.needsBuild(binaryPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !needsBuild {
|
||||
return binaryPath, nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(binaryPath), 0755); err != nil {
|
||||
return "", fmt.Errorf("create runtime bin dir: %w", err)
|
||||
}
|
||||
env := map[string]string{
|
||||
"GOOS": "linux",
|
||||
"GOARCH": runtime.GOARCH,
|
||||
}
|
||||
out, err := b.runner.Run(ctx, filepath.Join(b.projectRoot, "inbox"), env, "go", "build", "-o", binaryPath, "./cmd/lane-worker")
|
||||
if err != nil {
|
||||
return "", commandError("build lane worker binary", out, err)
|
||||
}
|
||||
return binaryPath, nil
|
||||
}
|
||||
|
||||
func (b *hostLaneWorkerBinary) needsBuild(binaryPath string) (bool, error) {
|
||||
return sourceTreeNeedsBuild(b.projectRoot, "inbox", binaryPath)
|
||||
}
|
||||
|
||||
func sourceTreeNeedsBuild(projectRoot, sourceSubdir, binaryPath string) (bool, error) {
|
||||
info, err := os.Stat(binaryPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("stat runtime binary: %w", err)
|
||||
}
|
||||
latest := info.ModTime()
|
||||
sourceRoot := filepath.Join(projectRoot, sourceSubdir)
|
||||
err = filepath.Walk(sourceRoot, func(path string, fileInfo os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if fileInfo.IsDir() {
|
||||
base := filepath.Base(path)
|
||||
if base == ".git" || base == "bin" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if fileInfo.ModTime().After(latest) {
|
||||
latest = fileInfo.ModTime()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("walk runtime source tree: %w", err)
|
||||
}
|
||||
return latest.After(info.ModTime()), nil
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
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 ""
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package workspaceruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/workspace"
|
||||
sqlitestore "inbox/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func TestEnsureRepairsMissingRegisteredWorkspaceWorktree(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 18, 2, 0, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
projectDir := filepath.Join(rootDir, "project")
|
||||
workspacesDir := filepath.Join(rootDir, "workspaces")
|
||||
if err := os.MkdirAll(projectDir, 0755); err != nil {
|
||||
t.Fatalf("MkdirAll(projectDir) error = %v", err)
|
||||
}
|
||||
|
||||
project, err := store.CreateProject(ctx, workspace.NormalizeProjectForCreate(workspace.Project{
|
||||
Slug: "demo-project",
|
||||
Name: "demo-project",
|
||||
RootPath: projectDir,
|
||||
DefaultBranch: "main",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProject() error = %v", err)
|
||||
}
|
||||
ws, err := store.CreateWorkspace(ctx, workspace.NormalizeWorkspaceForCreate(workspace.Workspace{
|
||||
ProjectID: project.ID,
|
||||
Slug: "todo",
|
||||
Name: "todo",
|
||||
RootPath: filepath.Join(workspacesDir, "todo"),
|
||||
BaseBranch: "main",
|
||||
WorktreeBranch: "worktree/todo",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkspace() error = %v", err)
|
||||
}
|
||||
|
||||
service := NewService(store, rootDir, workspacesDir, 3000, clock, nil)
|
||||
ensured, runtime, err := service.Ensure(ctx, ws.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Ensure() initial error = %v", err)
|
||||
}
|
||||
if !runtime.Ensured {
|
||||
t.Fatalf("expected initial ensure to succeed, got %#v", runtime)
|
||||
}
|
||||
if _, err := os.Stat(ensured.RootPath); err != nil {
|
||||
t.Fatalf("Stat(initial worktree) error = %v", err)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(ensured.RootPath); err != nil {
|
||||
t.Fatalf("RemoveAll(worktree) error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(ensured.RootPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected worktree path to be removed, stat err = %v", err)
|
||||
}
|
||||
|
||||
ensured, runtime, err = service.Ensure(ctx, ws.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Ensure() repair error = %v", err)
|
||||
}
|
||||
if !runtime.Ensured {
|
||||
t.Fatalf("expected repaired ensure to succeed, got %#v", runtime)
|
||||
}
|
||||
if _, err := os.Stat(ensured.RootPath); err != nil {
|
||||
t.Fatalf("Stat(repaired worktree) error = %v", err)
|
||||
}
|
||||
if ensured.ProvisionState != "ready" {
|
||||
t.Fatalf("expected workspace to be ready after repair, got %#v", ensured)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user