diff --git a/daemon/api.go b/daemon/api.go index 3df1526..514bf16 100644 --- a/daemon/api.go +++ b/daemon/api.go @@ -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 } diff --git a/daemon/api_extra_test.go b/daemon/api_extra_test.go index 69c1179..c577916 100644 --- a/daemon/api_extra_test.go +++ b/daemon/api_extra_test.go @@ -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) + } +} diff --git a/daemon/docker.go b/daemon/docker.go index 6b496ee..66301b0 100644 --- a/daemon/docker.go +++ b/daemon/docker.go @@ -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 { diff --git a/daemon/spawner_test.go b/daemon/spawner_test.go index 0f21807..bc884af 100644 --- a/daemon/spawner_test.go +++ b/daemon/spawner_test.go @@ -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) + } +} diff --git a/web/src/SpawnView.test.tsx b/web/src/SpawnView.test.tsx index 7c4823d..32f8405 100644 --- a/web/src/SpawnView.test.tsx +++ b/web/src/SpawnView.test.tsx @@ -235,7 +235,11 @@ describe("SpawnView repo picker", () => { render(tree(makeStore())); await flush(); expect(screen.getByLabelText("Spawn container")).toBeDisabled(); - expect(screen.getByText("select a repo above")).toBeInTheDocument(); + expect( + screen.getByText( + "select a repo above — or tick “Empty container” for a blank workspace", + ), + ).toBeInTheDocument(); }); }); @@ -739,4 +743,35 @@ describe("empty container spawn", () => { await flush(); expect(bodies[0]).toContain('"empty":true'); }); + + it("blank container: spawn without selecting a repo", async () => { + const bodies: string[] = []; + mockFetchJson((url, init) => { + if (init?.method === "POST" && url.endsWith("/api/spawn")) { + bodies.push(String(init.body)); + return { sessionId: "e2", imageUsed: "lvmh-worker:latest" }; + } + if (url.endsWith("/api/gitlab/status")) + return { connected: true, baseUrl: "https://gl", username: "a" }; + if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")]; + return []; + }); + render(tree(makeStore())); + await flush(); + + // no repo selected: Spawn disabled + const spawnBtn = screen.getByLabelText( + "Spawn container", + ) as HTMLButtonElement; + expect(spawnBtn.disabled).toBe(true); + + fireEvent.click(screen.getByRole("checkbox")); + expect(spawnBtn.disabled).toBe(false); + + fireEvent.click(spawnBtn); + await flush(); + expect(bodies[0]).toContain('"empty":true'); + expect(bodies[0]).toContain('"repo":""'); + expect(bodies[0]).not.toContain('"branch"'); + }); }); diff --git a/web/src/SpawnView.tsx b/web/src/SpawnView.tsx index 2e56967..a8b66da 100644 --- a/web/src/SpawnView.tsx +++ b/web/src/SpawnView.tsx @@ -41,9 +41,7 @@ function SpawnSteps({ state }: { state: string }): React.ReactNode { {SPAWN_STEPS.map((step, i) => ( i ? "step done" : idx === i ? "step current" : "step" - } + className={idx > i ? "step done" : idx === i ? "step current" : "step"} title={step} /> ))} @@ -167,15 +165,17 @@ export default function SpawnView({ store, pushToast }: Props) { r.path.toLowerCase().includes(query.toLowerCase()), ); - // spawn is only reachable from the Spawn button, which is disabled until a - // repo is selected. + // spawn is reachable with a repo selected, or with the blank-container + // checkbox and no repo (empty workspace). const spawn = async (): Promise => { setBusy(true); setError(""); try { const body = { - repo: selected!.path, - ...(branch.trim().length > 0 ? { branch: branch.trim() } : {}), + repo: selected?.path ?? "", + ...(selected !== null && branch.trim().length > 0 + ? { branch: branch.trim() } + : {}), ...(model.length > 0 ? { model } : {}), ...(empty ? { empty: true } : {}), }; @@ -207,8 +207,7 @@ export default function SpawnView({ store, pushToast }: Props) { const list = await store.refresh(); const s = list.find((x) => x.id === sessionId); if (s !== undefined && s.online) { - if (timerRef.current !== null) - window.clearInterval(timerRef.current); + if (timerRef.current !== null) window.clearInterval(timerRef.current); timerRef.current = null; navigate(`/s/${sessionId}`); } @@ -236,7 +235,8 @@ export default function SpawnView({ store, pushToast }: Props) { job.message !== undefined && job.message.length > 0 ? ` (${job.message})` : ""; - return `${job.repo}: ${job.state}${detail}`; + const label = job.repo.length > 0 ? job.repo : "blank container"; + return `${label}: ${job.state}${detail}`; } return "waiting for session to come online…"; }; @@ -246,9 +246,7 @@ export default function SpawnView({ store, pushToast }: Props) {

Spawn

- {error.length > 0 - ? `gitlab status failed: ${error}` - : "checking gitlab…"} + {error.length > 0 ? `gitlab status failed: ${error}` : "checking gitlab…"}

); @@ -338,8 +336,7 @@ export default function SpawnView({ store, pushToast }: Props) {
{r.path}
- default {r.defaultBranch} ·{" "} - {r.lastActivityAt.slice(0, 10)} + default {r.defaultBranch} · {r.lastActivityAt.slice(0, 10)}
{reg !== undefined && ( @@ -364,8 +361,7 @@ export default function SpawnView({ store, pushToast }: Props) { }} onKeyDown={(e) => { // let the native button handle Enter/Space - if (e.key === "Enter" || e.key === " ") - e.stopPropagation(); + if (e.key === "Enter" || e.key === " ") e.stopPropagation(); }} > ⚡ Prepare @@ -390,29 +386,28 @@ export default function SpawnView({ store, pushToast }: Props) { - {selected === null && ( + {selected === null && !empty && (

- select a repo above + select a repo above — or tick “Empty container” for a blank workspace

)} {selected !== null && (

- Will use:{" "} - {registration(selected.path)?.image ?? "base worker image"} + Will use: {registration(selected.path)?.image ?? "base worker image"}

)}