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:
+7
-1
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user