feat: empty-container spawn (scratch workspace, no repo volume); fix bridge session isolation — explicit SessionManager per spawn stops SDK resuming the repo's old session (which also overrode LVMH_MODEL)

This commit is contained in:
Raphael Westphal
2026-08-20 15:12:45 +02:00
parent bdfa61a8b2
commit f5ee3a0f2d
7 changed files with 134 additions and 46 deletions
+2 -1
View File
@@ -368,6 +368,7 @@ func (s *Server) handleSpawn(w http.ResponseWriter, r *http.Request) {
Repo string `json:"repo"` Repo string `json:"repo"`
Branch string `json:"branch"` Branch string `json:"branch"`
Model string `json:"model"` Model string `json:"model"`
Empty bool `json:"empty"`
} }
if !decodeBody(w, r, &body) { if !decodeBody(w, r, &body) {
return return
@@ -386,7 +387,7 @@ func (s *Server) handleSpawn(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "model must look like provider/model-id") writeError(w, http.StatusBadRequest, "model must look like provider/model-id")
return return
} }
res, err := s.spawn.Start(r.Context(), body.Repo, body.Branch, body.Model) res, err := s.spawn.Start(r.Context(), body.Repo, body.Branch, body.Model, body.Empty)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
+30 -21
View File
@@ -239,7 +239,7 @@ func (s *Spawner) deleteJob(sessionID string) {
} }
// Start launches the async spawn pipeline and returns the new sessionId. // Start launches the async spawn pipeline and returns the new sessionId.
func (s *Spawner) Start(ctx context.Context, repo, branch, model string) (SpawnResult, error) { func (s *Spawner) Start(ctx context.Context, repo, branch, model string, empty bool) (SpawnResult, error) {
exists, err := s.imageExists(ctx) exists, err := s.imageExists(ctx)
if err != nil { if err != nil {
return SpawnResult{}, fmt.Errorf("docker unavailable: %w", err) return SpawnResult{}, fmt.Errorf("docker unavailable: %w", err)
@@ -253,7 +253,7 @@ func (s *Spawner) Start(ctx context.Context, repo, branch, model string) (SpawnR
} }
sessionID := newUUID() sessionID := newUUID()
s.setJob(sessionID, repo, stateCloning, "", "") s.setJob(sessionID, repo, stateCloning, "", "")
go s.runJob(repo, branch, model, sessionID) go s.runJob(repo, branch, model, empty, sessionID)
return SpawnResult{SessionID: sessionID, ImageUsed: s.resolveImage(repo)}, nil return SpawnResult{SessionID: sessionID, ImageUsed: s.resolveImage(repo)}, nil
} }
@@ -292,7 +292,7 @@ func (s *Spawner) slugLock(slug string) *sync.Mutex {
} }
// runJob is the async clone→build→create→start pipeline. // runJob is the async clone→build→create→start pipeline.
func (s *Spawner) runJob(repo, branch, model, sessionID string) { func (s *Spawner) runJob(repo, branch, model string, empty bool, sessionID string) {
slug := repoSlug(repo) slug := repoSlug(repo)
lock := s.slugLock(slug) lock := s.slugLock(slug)
lock.Lock() lock.Lock()
@@ -308,7 +308,7 @@ func (s *Spawner) runJob(repo, branch, model, sessionID string) {
return return
} }
s.setJob(sessionID, repo, stateCreating, "", "") s.setJob(sessionID, repo, stateCreating, "", "")
containerID, image, err := s.createAndStart(s.ctx, repo, slug, model, sessionID) containerID, image, err := s.createAndStart(s.ctx, repo, slug, model, empty, sessionID)
if err != nil { if err != nil {
s.setJob(sessionID, repo, stateError, "", err.Error()) s.setJob(sessionID, repo, stateError, "", err.Error())
return return
@@ -522,7 +522,7 @@ func extractBuildError(body []byte) string {
// createAndStart provisions volumes, creates and starts the worker container. // createAndStart provisions volumes, creates and starts the worker container.
// A repo-registered custom image (see /api/repos) overrides the default // A repo-registered custom image (see /api/repos) overrides the default
// worker image; the ops agent builds and registers those. // worker image; the ops agent builds and registers those.
func (s *Spawner) createAndStart(ctx context.Context, repo, slug, model, sessionID string) (string, string, error) { func (s *Spawner) createAndStart(ctx context.Context, repo, slug, model string, empty bool, sessionID string) (string, string, error) {
image := s.resolveImage(repo) image := s.resolveImage(repo)
if image != imageRefWorker { if image != imageRefWorker {
exists, err := s.imageRefExists(ctx, image) exists, err := s.imageRefExists(ctx, image)
@@ -533,19 +533,25 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, model, session
return "", "", fmt.Errorf("custom image %s not built (ask the ops agent to build it)", image) return "", "", fmt.Errorf("custom image %s not built (ask the ops agent to build it)", image)
} }
} }
repoVolume := volumeRepoPrefix + slug var repoVolume string
fresh, err := s.ensureRepoVolume(ctx, repoVolume) if empty {
if err != nil { // Scratch spawn: no repo, no shared workspace — a blank slate.
return "", "", err repoVolume = ""
} } else {
if fresh { repoVolume = volumeRepoPrefix + slug
if err := s.seedVolume(ctx, slug, repoVolume, sessionID); err != nil { fresh, err := s.ensureRepoVolume(ctx, repoVolume)
// drop the half-seeded volume so the next spawn retries fresh if err != nil {
// instead of silently booting into an empty workspace. return "", "", err
if rmErr := s.cli.VolumeRemove(context.Background(), repoVolume, true); rmErr != nil { }
log.Printf("spawner: remove failed seed volume %s: %v", repoVolume, rmErr) if fresh {
if err := s.seedVolume(ctx, slug, repoVolume, sessionID); 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)
} }
return "", "", fmt.Errorf("seed %s: %w", repoVolume, err)
} }
} }
if _, err := s.cli.VolumeCreate(ctx, volume.CreateOptions{Name: volumeSessions}); err != nil { if _, err := s.cli.VolumeCreate(ctx, volume.CreateOptions{Name: volumeSessions}); err != nil {
@@ -555,11 +561,14 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, model, session
return "", "", fmt.Errorf("volume %s: %w", volumePiCache, err) return "", "", fmt.Errorf("volume %s: %w", volumePiCache, err)
} }
binds := []string{ var binds []string
repoVolume + ":" + workspaceMount, if !empty {
volumeSessions + ":" + sessionsMount, binds = append(binds, repoVolume+":"+workspaceMount)
volumePiCache + ":" + cacheMount,
} }
binds = append(binds,
volumeSessions+":"+sessionsMount,
volumePiCache+":"+cacheMount,
)
// Host pi credentials (OAuth tokens for anthropic etc.), read-only, so // Host pi credentials (OAuth tokens for anthropic etc.), read-only, so
// spawned agents can use every model the catalog offers. // spawned agents can use every model the catalog offers.
if hostAgent := os.Getenv(envHostPiAgentDir); hostAgent != "" { if hostAgent := os.Getenv(envHostPiAgentDir); hostAgent != "" {
+2 -2
View File
@@ -495,7 +495,7 @@ func TestSpawnerSecretsBinds(t *testing.T) {
sec := t.TempDir() sec := t.TempDir()
t.Setenv(envSecretsDir, sec) t.Setenv(envSecretsDir, sec)
res, err := sp.Start(context.Background(), "group/project", "", "") res, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -526,7 +526,7 @@ func TestWorkerBindsCaches(t *testing.T) {
t.Setenv(envPlaywrightCacheDir, "/host/pw") t.Setenv(envPlaywrightCacheDir, "/host/pw")
t.Setenv(envCloakCacheDir, "/host/cb") t.Setenv(envCloakCacheDir, "/host/cb")
res, err := sp.Start(context.Background(), "group/project", "", "") res, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
+52 -21
View File
@@ -29,7 +29,7 @@ func TestSpawnerStartHappyPath(t *testing.T) {
f := newFakeDocker() f := newFakeDocker()
sp, store := newTestSpawner(t, f) sp, store := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "group/project", "main", "") res, err := sp.Start(context.Background(), "group/project", "main", "", false)
if err != nil { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -148,7 +148,7 @@ func TestSpawnerBuildsImageWhenMissing(t *testing.T) {
f.images = 0 // image absent → ensureImage must build f.images = 0 // image absent → ensureImage must build
sp, _ := newTestSpawner(t, f) sp, _ := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "group/project", "", "") res, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -172,7 +172,7 @@ func TestSpawnerStartValidatesDockerAndDockerfile(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("NewSpawner: %v", err) t.Fatalf("NewSpawner: %v", err)
} }
_, err = sp2.Start(context.Background(), "group/project", "", "") _, err = sp2.Start(context.Background(), "group/project", "", "", false)
if err == nil || !strings.Contains(err.Error(), "no worker Dockerfile") { if err == nil || !strings.Contains(err.Error(), "no worker Dockerfile") {
t.Fatalf("Start without dockerfile err = %v", err) t.Fatalf("Start without dockerfile err = %v", err)
} }
@@ -247,7 +247,7 @@ func TestSpawnerRunJobErrorStates(t *testing.T) {
tc.setup(t, f) tc.setup(t, f)
} }
sp, _ := newTestSpawner(t, f) sp, _ := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "group/project", "", "") res, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -384,11 +384,11 @@ func TestSpawnerSameRepoSpawnsSerialize(t *testing.T) {
t.Fatal("slugLock must return distinct mutexes per slug") t.Fatal("slugLock must return distinct mutexes per slug")
} }
res1, err := sp.Start(context.Background(), "group/project", "", "") res1, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start 1: %v", err) t.Fatalf("Start 1: %v", err)
} }
res2, err := sp.Start(context.Background(), "group/project", "", "") res2, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start 2: %v", err) t.Fatalf("Start 2: %v", err)
} }
@@ -436,7 +436,7 @@ func TestSpawnerCloneUsesHeaderAuthNotURLCredentials(t *testing.T) {
t.Fatalf("set token: %v", err) t.Fatalf("set token: %v", err)
} }
res, err := sp.Start(context.Background(), "group/project", "", "") res, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -491,7 +491,7 @@ func TestSpawnerFailedSeedRemovesRepoVolume(t *testing.T) {
f.failArchive = true // CopyToContainer fails → seed fails f.failArchive = true // CopyToContainer fails → seed fails
sp, _ := newTestSpawner(t, f) sp, _ := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "group/project", "", "") res, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -514,7 +514,7 @@ func TestSpawnerFailedSeedSurvivesVolumeRemoveFailure(t *testing.T) {
f.failVolumeDelete = true // cleanup itself fails; seed error still surfaces f.failVolumeDelete = true // cleanup itself fails; seed error still surfaces
sp, _ := newTestSpawner(t, f) sp, _ := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "group/project", "", "") res, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -722,7 +722,7 @@ func TestSpawnerStartDockerUnavailable(t *testing.T) {
// because the client is already built; close the fake server instead. // because the client is already built; close the fake server instead.
sp.cli.Close() sp.cli.Close()
closeDocker(t, sp) closeDocker(t, sp)
if _, err := sp.Start(context.Background(), "group/project", "", ""); err == nil || !strings.Contains(err.Error(), "docker unavailable") { if _, err := sp.Start(context.Background(), "group/project", "", "", false); err == nil || !strings.Contains(err.Error(), "docker unavailable") {
t.Fatalf("Start with dead docker = %v, want docker unavailable", err) t.Fatalf("Start with dead docker = %v, want docker unavailable", err)
} }
} }
@@ -799,7 +799,7 @@ func TestSpawnerSessionsVolumeCreateFails(t *testing.T) {
f.volume[volumeRepoPrefix+repoSlug("group/project")] = true // repo volume exists → skip seed f.volume[volumeRepoPrefix+repoSlug("group/project")] = true // repo volume exists → skip seed
f.failVolumeCreate = true f.failVolumeCreate = true
sp, _ := newTestSpawner(t, f) sp, _ := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "group/project", "", "") res, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -844,7 +844,7 @@ func TestSpawnerStoreFailures(t *testing.T) {
dead2 := openTestStore(t) dead2 := openTestStore(t)
_ = dead2.Close() _ = dead2.Close()
sp2.store = dead2 sp2.store = dead2
res, err := sp2.Start(context.Background(), "group/project", "", "") res, err := sp2.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -877,7 +877,7 @@ func TestSpawnerWorkerCreateStartFailures(t *testing.T) {
sp, _ := newTestSpawner(t, f) sp, _ := newTestSpawner(t, f)
f.failCreate = true f.failCreate = true
res, err := sp.Start(context.Background(), "group/project", "", "") res, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -887,7 +887,7 @@ func TestSpawnerWorkerCreateStartFailures(t *testing.T) {
f.failCreate = false f.failCreate = false
f.failStart = true f.failStart = true
res2, err := sp.Start(context.Background(), "group/project", "", "") res2, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start 2: %v", err) t.Fatalf("Start 2: %v", err)
} }
@@ -906,7 +906,7 @@ func TestSpawnerBuildHTTPErrors(t *testing.T) {
f.images = 0 f.images = 0
f.failBuildHTTP = true // /build endpoint itself 500s f.failBuildHTTP = true // /build endpoint itself 500s
sp, _ := newTestSpawner(t, f) sp, _ := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "group/project", "", "") res, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -1186,7 +1186,7 @@ func TestSpawnerStartDedupesImageList(t *testing.T) {
useFakeGit(t, fakeGitModeOK) useFakeGit(t, fakeGitModeOK)
f := newFakeDocker() f := newFakeDocker()
sp, _ := newTestSpawner(t, f) sp, _ := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "group/project", "", "") res, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -1209,7 +1209,7 @@ func TestSpawnerCustomImageUsed(t *testing.T) {
t.Fatalf("set repo image: %v", err) t.Fatalf("set repo image: %v", err)
} }
res, err := sp.Start(context.Background(), "group/project", "", "") res, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -1238,7 +1238,7 @@ func TestSpawnerCustomImageMissing(t *testing.T) {
t.Fatalf("set repo image: %v", err) t.Fatalf("set repo image: %v", err)
} }
res, err := sp.Start(context.Background(), "group/project", "", "") res, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -1266,7 +1266,7 @@ func TestSpawnerCustomImageDeleteFallsBack(t *testing.T) {
t.Fatalf("delete repo image: %v", err) t.Fatalf("delete repo image: %v", err)
} }
res, err := sp.Start(context.Background(), "group/project", "", "") res, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -1287,7 +1287,7 @@ func TestSpawnerCustomImageCheckDockerDead(t *testing.T) {
t.Fatalf("set repo image: %v", err) t.Fatalf("set repo image: %v", err)
} }
sp.images0AndDead(t) sp.images0AndDead(t)
_, _, err := sp.createAndStart(context.Background(), "group/project", repoSlug("group/project"), "", "s1") _, _, err := sp.createAndStart(context.Background(), "group/project", repoSlug("group/project"), "", false, "s1")
if err == nil || !strings.Contains(err.Error(), "docker unavailable") { if err == nil || !strings.Contains(err.Error(), "docker unavailable") {
t.Fatalf("createAndStart with dead docker = %v, want docker unavailable", err) t.Fatalf("createAndStart with dead docker = %v, want docker unavailable", err)
} }
@@ -1298,7 +1298,7 @@ func TestSpawnerModelEnv(t *testing.T) {
f := newFakeDocker() f := newFakeDocker()
sp, _ := newTestSpawner(t, f) sp, _ := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "group/project", "", "zai-renaud/glm-5.2") res, err := sp.Start(context.Background(), "group/project", "", "zai-renaud/glm-5.2", false)
if err != nil { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -1317,3 +1317,34 @@ func TestSpawnerModelEnv(t *testing.T) {
t.Fatalf("LVMH_MODEL missing from %v", creates[0].Env) t.Fatalf("LVMH_MODEL missing from %v", creates[0].Env)
} }
} }
func TestSpawnerEmptyScratch(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "group/project", "", "", true)
if err != nil {
t.Fatalf("Start: %v", err)
}
waitJobState(t, sp, res.SessionID, stateRunning)
creates := f.createsByName("lvmh-agent-")
if len(creates) != 1 {
t.Fatalf("creates = %d", len(creates))
}
binds := creates[0].HostConfig.Binds
for _, b := range binds {
if b == "lvmh-repo-group--project-ab12cd:/workspace" {
t.Fatalf("empty spawn must not mount the repo volume: %v", binds)
}
}
hasSessions := false
for _, b := range binds {
if b == "lvmh-sessions:/pi-sessions" {
hasSessions = true
}
}
if !hasSessions {
t.Fatalf("sessions volume missing: %v", binds)
}
}
+9 -1
View File
@@ -7,6 +7,7 @@ import { existsSync } from "node:fs";
import { import {
createAgentSession, createAgentSession,
ModelRuntime, ModelRuntime,
SessionManager,
} from "/usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/index.js"; } from "/usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/index.js";
const SETUP_PATH = "/workspace/.lvmh/setup.sh"; const SETUP_PATH = "/workspace/.lvmh/setup.sh";
@@ -85,8 +86,15 @@ try {
console.error(`[lvmh-bridge] model resolution failed:`, err); console.error(`[lvmh-bridge] model resolution failed:`, err);
} }
} }
// Fresh session per spawn: without an explicit sessionManager the SDK
// picks up an existing session file for the cwd — sharing one transcript
// (and its saved model) across every spawn of the repo. A dedicated dir
// per daemon session id keeps spawns isolated and LVMH_MODEL authoritative.
const sessionRoot = process.env.PI_SESSION_DIR ?? "/pi-sessions";
const sessionDir = `${sessionRoot}/${process.env.LVMH_SESSION_ID ?? "default"}`;
const sessionManager = SessionManager.create(process.cwd(), sessionDir);
const { session } = await createAgentSession( const { session } = await createAgentSession(
model === undefined ? {} : { model }, model === undefined ? { sessionManager } : { model, sessionManager },
); );
// SDK does not bind extensions implicitly (unlike TUI/RPC modes); without // SDK does not bind extensions implicitly (unlike TUI/RPC modes); without
// bindExtensions the session_start event never fires, so the lvmh plugin // bindExtensions the session_start event never fires, so the lvmh plugin
+25
View File
@@ -715,3 +715,28 @@ describe("spawn model selection", () => {
expect(bodies[0]).toContain('"model":"zai-renaud/glm-5.3"'); expect(bodies[0]).toContain('"model":"zai-renaud/glm-5.3"');
}); });
}); });
describe("empty container spawn", () => {
it("checkbox sends empty:true and resets after", async () => {
const bodies: string[] = [];
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
bodies.push(String(init.body));
return { sessionId: "e1", 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();
fireEvent.click(screen.getByText("g/p"));
const cb = screen.getByRole("checkbox");
fireEvent.click(cb);
expect(cb).toBeChecked();
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(bodies[0]).toContain('"empty":true');
});
});
+14
View File
@@ -61,6 +61,7 @@ export default function SpawnView({ store, pushToast }: Props) {
const [query, setQuery] = useState<string>(""); const [query, setQuery] = useState<string>("");
const [selected, setSelected] = useState<Repo | null>(null); const [selected, setSelected] = useState<Repo | null>(null);
const [branch, setBranch] = useState<string>(""); const [branch, setBranch] = useState<string>("");
const [empty, setEmpty] = useState<boolean>(false);
const [model, setModel] = useState<string>(""); const [model, setModel] = useState<string>("");
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null); const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
const [busy, setBusy] = useState<boolean>(false); const [busy, setBusy] = useState<boolean>(false);
@@ -176,6 +177,7 @@ export default function SpawnView({ store, pushToast }: Props) {
repo: selected!.path, repo: selected!.path,
...(branch.trim().length > 0 ? { branch: branch.trim() } : {}), ...(branch.trim().length > 0 ? { branch: branch.trim() } : {}),
...(model.length > 0 ? { model } : {}), ...(model.length > 0 ? { model } : {}),
...(empty ? { empty: true } : {}),
}; };
const res = await fetchJson<SpawnResponse>(Route.Spawn, { const res = await fetchJson<SpawnResponse>(Route.Spawn, {
method: "POST", method: "POST",
@@ -429,6 +431,18 @@ export default function SpawnView({ store, pushToast }: Props) {
))} ))}
</select> </select>
</div> </div>
<label
className="repo-meta"
style={{ marginTop: 8, display: "flex", gap: 6, alignItems: "center" }}
>
<input
type="checkbox"
checked={empty}
onChange={(e) => setEmpty(e.target.checked)}
disabled={busy}
/>
Empty container (no repo blank workspace)
</label>
{error.length > 0 && <p className="error-text">{error}</p>} {error.length > 0 && <p className="error-text">{error}</p>}
</div> </div>
</> </>