feat: start on a blank container — spawn with empty:true and no repo (skip clone, default worker image); Spawn UI enables blank spawn without repo selection

This commit is contained in:
2026-09-02 09:24:54 +00:00
parent c8cde3ff89
commit 0f245c9ee2
6 changed files with 105 additions and 28 deletions
+7 -1
View File
@@ -373,7 +373,13 @@ func (s *Server) handleSpawn(w http.ResponseWriter, r *http.Request) {
if !decodeBody(w, r, &body) {
return
}
if !validRepoPath(body.Repo) {
// Blank-container spawns carry no repo; everything else must look like
// group/project.
if body.Repo == "" && !body.Empty {
writeError(w, http.StatusBadRequest, "repo required (or set empty for a blank container)")
return
}
if body.Repo != "" && !validRepoPath(body.Repo) {
writeError(w, http.StatusBadRequest, "repo must look like group/project")
return
}
+17
View File
@@ -682,3 +682,20 @@ func TestRenameAndSetModelBodyValidation(t *testing.T) {
t.Fatalf("bad model body = %d, want 400", code)
}
}
func TestAPISpawnBlankContainerNoRepo(t *testing.T) {
ts, _ := newSpawnAPIServer(t)
// no repo and not empty → 400
if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", testToken, `{"repo":""}`); code != http.StatusBadRequest {
t.Fatalf("no-repo non-empty spawn = %d %s, want 400", code, body)
}
// blank container: no repo required
code, body := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", testToken, `{"empty":true}`)
if code != http.StatusCreated {
t.Fatalf("blank spawn = %d %s, want 201", code, body)
}
if !strings.Contains(body, `"sessionId"`) {
t.Fatalf("blank spawn body = %s, want sessionId", body)
}
}
+6 -3
View File
@@ -298,9 +298,12 @@ func (s *Spawner) runJob(repo, branch, model string, empty bool, sessionID strin
lock.Lock()
defer lock.Unlock()
if err := s.cloneOrUpdate(s.ctx, repo, branch, slug); err != nil {
s.setJob(sessionID, repo, stateError, "", err.Error())
return
// blank-container spawn: nothing to clone or update
if repo != "" {
if err := s.cloneOrUpdate(s.ctx, repo, branch, slug); err != nil {
s.setJob(sessionID, repo, stateError, "", err.Error())
return
}
}
s.setJob(sessionID, repo, stateBuilding, "", "")
if err := s.ensureImage(s.ctx); err != nil {
+21
View File
@@ -1348,3 +1348,24 @@ func TestSpawnerEmptyScratch(t *testing.T) {
t.Fatalf("sessions volume missing: %v", binds)
}
}
func TestSpawnerEmptyScratchNoRepo(t *testing.T) {
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "", "", "", true)
if err != nil {
t.Fatalf("Start with no repo: %v", err)
}
waitJobState(t, sp, res.SessionID, stateRunning)
creates := f.createsByName("lvmh-agent-")
if len(creates) != 1 {
t.Fatalf("creates = %d, want 1", len(creates))
}
for _, b := range creates[0].HostConfig.Binds {
if b == "lvmh-sessions:/pi-sessions" || b == "lvmh-pi-cache:/root/.pi/agent/cache" {
continue
}
t.Fatalf("blank spawn must mount no repo volume: %v", creates[0].HostConfig.Binds)
}
}