fix(review round 1): daemon PAT-header auth, job pruning, session-split batches, streaming tars, seed cleanup, ping/pong, byte caps, branch switch, slug --, ctx cancel, Init:true, pagination, latest/before events; web newest-window history + load-older, offline busy gate, staleness guards, memo store, auth probe, scroll key, focus-visible, draft restore, poll dedup; 106+181 tests, coverage 96.2%/95.1%+

This commit is contained in:
Raphael Westphal
2026-08-18 18:49:52 +02:00
parent 64e45e1a82
commit 6aac763563
25 changed files with 2564 additions and 959 deletions
+111 -29
View File
@@ -12,6 +12,7 @@ import (
"errors"
"fmt"
"io"
"log"
"net/url"
"os"
"os/exec"
@@ -61,6 +62,7 @@ const (
envLVMHRepo string = "LVMH_REPO"
stopTimeoutSeconds int = 10
buildContextReadLimit int64 = 64 << 20
maxSpawnJobs int = 50 // jobs map pruned to this many entries
)
var errNoContainer = errors.New("no container for session")
@@ -70,7 +72,7 @@ var repoPathRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)+$`)
// validRepoPath accepts "group/project" style paths (at least two segments).
func validRepoPath(repo string) bool { return repoPathRe.MatchString(repo) }
func repoSlug(repo string) string { return strings.ReplaceAll(repo, "/", "-") }
func repoSlug(repo string) string { return strings.ReplaceAll(repo, "/", "--") }
// newUUID returns a random RFC 4122 v4 UUID string.
func newUUID() string {
@@ -112,6 +114,7 @@ type SpawnResult struct {
// Spawner owns the docker client, per-repo clone serialization and job state.
type Spawner struct {
ctx context.Context // base ctx for async jobs (cancelled on shutdown)
store *Store
hub *Hub
cli *client.Client
@@ -125,16 +128,18 @@ type Spawner struct {
mu sync.Mutex
jobs map[string]*SpawnJob // keyed by sessionId
jobOrder []string // insertion order of jobs, for pruning
slugLocks map[string]*sync.Mutex
}
func NewSpawner(store *Store, hub *Hub, baseURL string) (*Spawner, error) {
func NewSpawner(ctx context.Context, store *Store, hub *Hub, baseURL string) (*Spawner, error) {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, fmt.Errorf("docker client: %w", err)
}
dockerfile := envOr(envWorkerDockerfile, defaultDockerfile)
return &Spawner{
ctx: ctx,
store: store,
hub: hub,
cli: cli,
@@ -173,6 +178,13 @@ func (s *Spawner) setJob(sessionID, repo, state, containerID, message string) {
if !ok {
j = &SpawnJob{SessionID: sessionID, Repo: repo}
s.jobs[sessionID] = j
s.jobOrder = append(s.jobOrder, sessionID)
if len(s.jobOrder) > maxSpawnJobs {
oldest := s.jobOrder[0]
copy(s.jobOrder, s.jobOrder[1:])
s.jobOrder = s.jobOrder[:len(s.jobOrder)-1]
delete(s.jobs, oldest)
}
}
j.Repo = repo
j.State = state
@@ -183,6 +195,23 @@ func (s *Spawner) setJob(sessionID, repo, state, containerID, message string) {
s.hub.BroadcastSpawnStatus()
}
// deleteJob drops a session's job from the map and the insertion order.
func (s *Spawner) deleteJob(sessionID string) {
s.mu.Lock()
if _, ok := s.jobs[sessionID]; !ok {
s.mu.Unlock()
return
}
delete(s.jobs, sessionID)
for i, id := range s.jobOrder {
if id == sessionID {
s.jobOrder = append(s.jobOrder[:i], s.jobOrder[i+1:]...)
break
}
}
s.mu.Unlock()
}
// Start launches the async spawn pipeline and returns the new sessionId.
func (s *Spawner) Start(ctx context.Context, repo, branch string) (SpawnResult, error) {
if _, err := s.imageExists(ctx); err != nil {
@@ -234,12 +263,12 @@ func (s *Spawner) runJob(repo, branch, sessionID string) {
return
}
s.setJob(sessionID, repo, stateBuilding, "", "")
if err := s.ensureImage(context.Background()); err != nil {
if err := s.ensureImage(s.ctx); err != nil {
s.setJob(sessionID, repo, stateError, "", err.Error())
return
}
s.setJob(sessionID, repo, stateCreating, "", "")
containerID, err := s.createAndStart(context.Background(), repo, slug, sessionID)
containerID, err := s.createAndStart(s.ctx, repo, slug, sessionID)
if err != nil {
s.setJob(sessionID, repo, stateError, "", err.Error())
return
@@ -252,6 +281,8 @@ func (s *Spawner) runJob(repo, branch, sessionID string) {
}
// cloneOrUpdate clones the repo into reposDir/<slug> or fast-forwards it.
// Credentials never appear in the clone URL (which git persists into
// .git/config); auth is passed per-invocation via http.extraHeader.
func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
dir := filepath.Join(s.reposDir, slug)
cloneURL, err := s.cloneURL(repo)
@@ -259,7 +290,16 @@ func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
return err
}
if st, err := os.Stat(filepath.Join(dir, ".git")); err == nil && st.IsDir() {
if err := gitRun(dir, "pull", "--ff-only"); err != nil {
auth := s.gitAuthArgs()
if branch != "" && gitBranch(dir) != branch {
if err := gitRun(dir, append(append([]string{}, auth...), "fetch", "origin", branch)...); err != nil {
return fmt.Errorf("git fetch %s %s: %w", repo, branch, err)
}
if err := gitRun(dir, "checkout", branch); err != nil {
return fmt.Errorf("git checkout %s: %w", repo, err)
}
}
if err := gitRun(dir, append(append([]string{}, auth...), "pull", "--ff-only")...); err != nil {
return fmt.Errorf("git pull %s: %w", repo, err)
}
return nil
@@ -267,7 +307,7 @@ func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
if err := os.MkdirAll(s.reposDir, 0o755); err != nil {
return err
}
args := []string{"clone"}
args := append(s.gitAuthArgs(), "clone")
if branch != "" {
args = append(args, "--branch", branch)
}
@@ -278,18 +318,34 @@ func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
return nil
}
// cloneURL builds an authenticated https clone URL when a PAT is stored.
// cloneURL builds the clean https clone URL (never credential-bearing).
func (s *Spawner) cloneURL(repo string) (string, error) {
u, err := url.Parse(s.baseURL + "/" + repo + ".git")
if err != nil {
return "", err
}
if token, ok, _ := s.store.GetSetting(settingGitLabToken); ok && token != "" && u.User == nil {
u.User = url.User(token)
}
return u.String(), nil
}
// gitAuthArgs returns per-invocation git args carrying the stored PAT via
// an HTTP header, or nil when no PAT is stored.
func (s *Spawner) gitAuthArgs() []string {
token, ok, _ := s.store.GetSetting(settingGitLabToken)
if !ok || token == "" {
return nil
}
return []string{"-c", "http.extraHeader=Authorization: token " + token}
}
// gitBranch returns the checked-out branch of an existing clone, "" on failure.
func gitBranch(dir string) string {
out, err := exec.Command("git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func gitRun(dir string, args ...string) error {
cmd := exec.Command("git", args...)
if dir != "" {
@@ -327,17 +383,29 @@ func (s *Spawner) ensureImage(ctx context.Context) error {
if err != nil {
return err
}
var buf bytes.Buffer
if err := tarDir(&buf, s.buildContext); err != nil {
return fmt.Errorf("build context %s: %w", s.buildContext, err)
}
resp, err := s.cli.ImageBuild(ctx, &buf, build.ImageBuildOptions{
pr, pw := io.Pipe()
tarDone := make(chan error, 1)
go func() {
err := tarDir(pw, s.buildContext)
_ = pw.CloseWithError(err)
tarDone <- err
}()
resp, buildErr := s.cli.ImageBuild(ctx, pr, build.ImageBuildOptions{
Tags: []string{imageRefWorker},
Dockerfile: relDockerfile,
Remove: true,
})
if err != nil {
return fmt.Errorf("docker build: %w", err)
if buildErr != nil {
_ = pr.CloseWithError(buildErr) // unblock the tar goroutine
}
if tarErr := <-tarDone; tarErr != nil {
if resp.Body != nil {
_ = resp.Body.Close()
}
return fmt.Errorf("build context %s: %w", s.buildContext, tarErr)
}
if buildErr != nil {
return fmt.Errorf("docker build: %w", buildErr)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, buildContextReadLimit))
@@ -378,6 +446,11 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri
}
if fresh {
if err := s.seedVolume(ctx, slug, repoVolume); err != nil {
// drop the half-seeded volume so the next spawn retries fresh
// instead of silently booting into an empty workspace.
if rmErr := s.cli.VolumeRemove(context.Background(), repoVolume, true); rmErr != nil {
log.Printf("spawner: remove failed seed volume %s: %v", repoVolume, rmErr)
}
return "", fmt.Errorf("seed %s: %w", repoVolume, err)
}
}
@@ -409,6 +482,7 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri
Binds: binds,
NetworkMode: container.NetworkMode(s.network),
AutoRemove: false,
Init: &[]bool{true}[0], // tini reaps bridge setup-hook zombies
}
name := "lvmh-agent-" + strings.ReplaceAll(sessionID, "-", "")[:12]
created, err := s.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, name)
@@ -445,18 +519,32 @@ func (s *Spawner) seedVolume(ctx context.Context, slug, repoVolume string) error
}
hostCfg := &container.HostConfig{
Binds: []string{repoVolume + ":" + workspaceMount},
Init: &[]bool{true}[0],
}
created, err := s.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, "lvmh-seed-"+slug)
if err != nil {
return err
}
defer s.removeContainer(context.Background(), created.ID)
var buf bytes.Buffer
if err := tarDir(&buf, repoDir); err != nil {
return fmt.Errorf("tar clone: %w", err)
pr, pw := io.Pipe()
tarDone := make(chan error, 1)
go func() {
err := tarDir(pw, repoDir)
_ = pw.CloseWithError(err)
tarDone <- err
}()
copyErr := s.cli.CopyToContainer(ctx, created.ID, workspaceMount, pr, container.CopyToContainerOptions{})
if copyErr != nil {
_ = pr.CloseWithError(copyErr) // unblock the tar goroutine
}
if err := s.cli.CopyToContainer(ctx, created.ID, workspaceMount, &buf, container.CopyToContainerOptions{}); err != nil {
return fmt.Errorf("copy into volume: %w", err)
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
if tarErr := <-tarDone; tarErr != nil {
return fmt.Errorf("tar clone: %w", tarErr)
}
if copyErr != nil {
return fmt.Errorf("copy into volume: %w", copyErr)
}
return nil
}
@@ -484,13 +572,7 @@ func (s *Spawner) RemoveSession(ctx context.Context, sessionID string) error {
if err := s.store.DeleteContainer(sessionID); err != nil {
return err
}
s.mu.Lock()
if j, ok := s.jobs[sessionID]; ok {
j.State = stateError
j.Message = "container removed"
j.UpdatedAt = time.Now().UnixMilli()
}
s.mu.Unlock()
s.deleteJob(sessionID)
s.hub.BroadcastSpawnStatus()
return nil
}