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) { if !decodeBody(w, r, &body) {
return 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") writeError(w, http.StatusBadRequest, "repo must look like group/project")
return return
} }
+17
View File
@@ -682,3 +682,20 @@ func TestRenameAndSetModelBodyValidation(t *testing.T) {
t.Fatalf("bad model body = %d, want 400", code) 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)
}
}
+3
View File
@@ -298,10 +298,13 @@ func (s *Spawner) runJob(repo, branch, model string, empty bool, sessionID strin
lock.Lock() lock.Lock()
defer lock.Unlock() defer lock.Unlock()
// blank-container spawn: nothing to clone or update
if repo != "" {
if err := s.cloneOrUpdate(s.ctx, repo, branch, slug); err != nil { if err := s.cloneOrUpdate(s.ctx, repo, branch, slug); err != nil {
s.setJob(sessionID, repo, stateError, "", err.Error()) s.setJob(sessionID, repo, stateError, "", err.Error())
return return
} }
}
s.setJob(sessionID, repo, stateBuilding, "", "") s.setJob(sessionID, repo, stateBuilding, "", "")
if err := s.ensureImage(s.ctx); err != nil { if err := s.ensureImage(s.ctx); err != nil {
s.setJob(sessionID, repo, stateError, "", err.Error()) s.setJob(sessionID, repo, stateError, "", err.Error())
+21
View File
@@ -1348,3 +1348,24 @@ func TestSpawnerEmptyScratch(t *testing.T) {
t.Fatalf("sessions volume missing: %v", binds) 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)
}
}
+36 -1
View File
@@ -235,7 +235,11 @@ describe("SpawnView repo picker", () => {
render(tree(makeStore())); render(tree(makeStore()));
await flush(); await flush();
expect(screen.getByLabelText("Spawn container")).toBeDisabled(); 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(); await flush();
expect(bodies[0]).toContain('"empty":true'); 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"');
});
}); });
+18 -23
View File
@@ -41,9 +41,7 @@ function SpawnSteps({ state }: { state: string }): React.ReactNode {
{SPAWN_STEPS.map((step, i) => ( {SPAWN_STEPS.map((step, i) => (
<span <span
key={step} key={step}
className={ className={idx > i ? "step done" : idx === i ? "step current" : "step"}
idx > i ? "step done" : idx === i ? "step current" : "step"
}
title={step} title={step}
/> />
))} ))}
@@ -167,15 +165,17 @@ export default function SpawnView({ store, pushToast }: Props) {
r.path.toLowerCase().includes(query.toLowerCase()), r.path.toLowerCase().includes(query.toLowerCase()),
); );
// spawn is only reachable from the Spawn button, which is disabled until a // spawn is reachable with a repo selected, or with the blank-container
// repo is selected. // checkbox and no repo (empty workspace).
const spawn = async (): Promise<void> => { const spawn = async (): Promise<void> => {
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
const body = { const body = {
repo: selected!.path, repo: selected?.path ?? "",
...(branch.trim().length > 0 ? { branch: branch.trim() } : {}), ...(selected !== null && branch.trim().length > 0
? { branch: branch.trim() }
: {}),
...(model.length > 0 ? { model } : {}), ...(model.length > 0 ? { model } : {}),
...(empty ? { empty: true } : {}), ...(empty ? { empty: true } : {}),
}; };
@@ -207,8 +207,7 @@ export default function SpawnView({ store, pushToast }: Props) {
const list = await store.refresh(); const list = await store.refresh();
const s = list.find((x) => x.id === sessionId); const s = list.find((x) => x.id === sessionId);
if (s !== undefined && s.online) { if (s !== undefined && s.online) {
if (timerRef.current !== null) if (timerRef.current !== null) window.clearInterval(timerRef.current);
window.clearInterval(timerRef.current);
timerRef.current = null; timerRef.current = null;
navigate(`/s/${sessionId}`); navigate(`/s/${sessionId}`);
} }
@@ -236,7 +235,8 @@ export default function SpawnView({ store, pushToast }: Props) {
job.message !== undefined && job.message.length > 0 job.message !== undefined && job.message.length > 0
? ` (${job.message})` ? ` (${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…"; return "waiting for session to come online…";
}; };
@@ -246,9 +246,7 @@ export default function SpawnView({ store, pushToast }: Props) {
<div className="page"> <div className="page">
<h1>Spawn</h1> <h1>Spawn</h1>
<p className="empty"> <p className="empty">
{error.length > 0 {error.length > 0 ? `gitlab status failed: ${error}` : "checking gitlab…"}
? `gitlab status failed: ${error}`
: "checking gitlab…"}
</p> </p>
</div> </div>
); );
@@ -338,8 +336,7 @@ export default function SpawnView({ store, pushToast }: Props) {
<div style={{ minWidth: 0, flex: 1 }}> <div style={{ minWidth: 0, flex: 1 }}>
<div className="repo-path">{r.path}</div> <div className="repo-path">{r.path}</div>
<div className="repo-meta"> <div className="repo-meta">
default {r.defaultBranch} ·{" "} default {r.defaultBranch} · {r.lastActivityAt.slice(0, 10)}
{r.lastActivityAt.slice(0, 10)}
</div> </div>
</div> </div>
{reg !== undefined && ( {reg !== undefined && (
@@ -364,8 +361,7 @@ export default function SpawnView({ store, pushToast }: Props) {
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
// let the native button handle Enter/Space // let the native button handle Enter/Space
if (e.key === "Enter" || e.key === " ") if (e.key === "Enter" || e.key === " ") e.stopPropagation();
e.stopPropagation();
}} }}
> >
Prepare Prepare
@@ -390,29 +386,28 @@ export default function SpawnView({ store, pushToast }: Props) {
<button <button
type="button" type="button"
className="btn-primary" className="btn-primary"
disabled={selected === null || busy} disabled={(selected === null && !empty) || busy}
aria-label="Spawn container" aria-label="Spawn container"
onClick={() => void spawn()} onClick={() => void spawn()}
> >
{busy ? "Spawning…" : "Spawn"} {busy ? "Spawning…" : "Spawn"}
</button> </button>
</div> </div>
{selected === null && ( {selected === null && !empty && (
<p className="repo-meta" style={{ marginTop: 8 }}> <p className="repo-meta" style={{ marginTop: 8 }}>
select a repo above select a repo above or tick Empty container for a blank workspace
</p> </p>
)} )}
{selected !== null && ( {selected !== null && (
<p className="repo-meta" style={{ marginTop: 8 }}> <p className="repo-meta" style={{ marginTop: 8 }}>
Will use:{" "} Will use: {registration(selected.path)?.image ?? "base worker image"}
{registration(selected.path)?.image ?? "base worker image"}
</p> </p>
)} )}
<div className="row" style={{ marginTop: 8 }}> <div className="row" style={{ marginTop: 8 }}>
<select <select
aria-label="Initial model" aria-label="Initial model"
value={model} value={model}
disabled={selected === null} disabled={selected === null && !empty}
onChange={(e) => setModel(e.target.value)} onChange={(e) => setModel(e.target.value)}
> >
<option value="">Default model (settings)</option> <option value="">Default model (settings)</option>