diff --git a/src/types/api.ts b/src/types/api.ts index 7adf4272..11000e5e 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -184,6 +184,15 @@ export interface CaseInfo { container: string; image?: string; path: string; + /** Directory INSIDE the container (defaults to `path` when unset). */ + containerWorkdir?: string; + /** + * False = an ADOPTED container the user built and runs. Only those may back + * several cases at once (classifyAdoptContainerConflict), so this is what lets + * the UI offer "duplicate for another directory" on exactly the right cases — + * an owned container's lifecycle belongs to its one case. + */ + owned?: boolean; network?: string; /** * CLIs available INSIDE the container. A container case runs its agents in diff --git a/src/web/public/index.html b/src/web/public/index.html index c1166437..39e956f1 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2906,6 +2906,13 @@ On: Codeman only runs docker exec into a container you already built and run — it never creates, starts, stops or removes it. The CLIs must already be installed and logged in inside it. +
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 2cd99370..68e382f4 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -3153,7 +3153,10 @@ Object.assign(CodemanApp.prototype, { const adopting = document.getElementById('dockerAdoptExisting')?.checked; if (adopting) modal.setAttribute('data-docker-adopt', '1'); else modal.removeAttribute('data-docker-adopt'); - if (adopting) void this._loadDockerContainerOptions(); + if (adopting) { + void this._loadDockerContainerOptions(); + void this._loadDockerCloneOptions(); + } }, /** @@ -3165,6 +3168,79 @@ Object.assign(CodemanApp.prototype, { * Best-effort by design — the endpoint returns [] for an unreachable daemon, * and an empty list simply leaves the field as plain text input. */ + /** + * Fill the "Duplicate an Existing Case" picker with the ADOPTED docker cases. + * + * One adopted container can back several cases, each pointing at a different + * directory inside it (classifyAdoptContainerConflict) — but re-typing the + * container, host and workspace by hand for every directory is exactly the + * friction that makes the capability go unused. Picking a case here fills those + * three and leaves only the two fields that MUST differ: the case name and the + * container workdir. + * + * ⚠️ Adopted cases only (`docker.owned === false`). An owned container's + * lifecycle belongs to its one case — a second case on it would be destroyed + * out from under itself by that case's recreate or delete — and the server + * refuses it, so offering it here would only produce a confusing error. + */ + async _loadDockerCloneOptions() { + const select = document.getElementById('dockerAdoptCloneFrom'); + const row = document.getElementById('dockerAdoptCloneRow'); + if (!select || !row) return; + let cases = []; + try { + const res = await fetch('/api/cases'); + const data = await res.json(); + cases = (Array.isArray(data) ? data : data?.data || []).filter( + (c) => c?.docker && c.docker.owned === false + ); + } catch { + cases = []; + } + select.textContent = ''; + const blank = document.createElement('option'); + blank.value = ''; + blank.textContent = 'Start from scratch'; + select.appendChild(blank); + for (const c of cases) { + const option = document.createElement('option'); + option.value = c.name; + // Server-supplied strings: textContent, never markup. + option.textContent = `${c.name} — ${c.docker.container}:${c.docker.containerWorkdir || c.docker.path}`; + option.dataset.container = c.docker.container; + option.dataset.hostId = c.docker.hostId; + option.dataset.path = c.docker.path; + select.appendChild(option); + } + // Nothing to duplicate yet: an empty picker is noise on the first adoption. + row.hidden = cases.length === 0; + }, + + /** + * Apply the picked case: carry over what STAYS the same, clear what must not. + * + * The two cleared fields are the point of the feature — a duplicate that kept + * the original's name would be rejected as an existing case, and one that kept + * its container workdir would be rejected as an exact twin (both by the server, + * with a clear message, but a form that pre-fills a value it knows will be + * refused is just a trap). + */ + applyDockerCloneSource() { + const select = document.getElementById('dockerAdoptCloneFrom'); + const option = select?.selectedOptions?.[0]; + if (!option || !option.value) return; + const set = (id, value) => { + const el = document.getElementById(id); + if (el) el.value = value || ''; + }; + set('dockerContainerName', option.dataset.container); + set('dockerHostId', option.dataset.hostId); + set('dockerWorkspacePath', option.dataset.path); + set('dockerCaseName', ''); + set('dockerAdoptWorkdir', ''); + document.getElementById('dockerAdoptWorkdir')?.focus(); + }, + async _loadDockerContainerOptions() { const list = document.getElementById('dockerContainerList'); if (!list) return; diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 31551df0..492b52a3 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -328,6 +328,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config container, image: host.image, path: dockerCase.hostWorkspacePath, + containerWorkdir: dockerCase.containerWorkdir ?? dockerCase.hostWorkspacePath, + owned: dockerCase.owned !== false, network: host.network ?? 'bridge', ...(dockerCase.availableModes ? { availableModes: dockerCase.availableModes } : {}), ...(dockerCase.owned === false ? { owned: false } : {}), @@ -1607,6 +1609,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config container, image: host.image, path: dockerCase.hostWorkspacePath, + containerWorkdir: dockerCase.containerWorkdir ?? dockerCase.hostWorkspacePath, + owned: dockerCase.owned !== false, network: host.network ?? 'bridge', }, }; diff --git a/test/docker-adopt-duplicate.test.ts b/test/docker-adopt-duplicate.test.ts new file mode 100644 index 00000000..3196907d --- /dev/null +++ b/test/docker-adopt-duplicate.test.ts @@ -0,0 +1,96 @@ +/** + * @fileoverview "Duplicate an existing case" in the container-adoption form. + * + * The server already allows one ADOPTED container to back several cases, each + * pointing at a different directory inside it (classifyAdoptContainerConflict). + * Re-typing the container, host and workspace by hand for every directory is the + * friction that would leave that capability unused, so the form carries them over + * and clears only the two fields that MUST differ. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, it, expect } from 'vitest'; + +const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8'); +const ui = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); +const routes = readFileSync(resolve(import.meta.dirname, '../src/web/routes/case-routes.ts'), 'utf8'); +const apiTypes = readFileSync(resolve(import.meta.dirname, '../src/types/api.ts'), 'utf8'); + +describe('the API exposes what the picker needs', () => { + it('reports each docker case s directory inside the container', () => { + // Without it the picker cannot show WHICH directory a case already uses, which + // is the one thing the user needs to see before choosing a different one. + expect(apiTypes).toMatch(/containerWorkdir\?: string;/); + expect(routes).toContain('containerWorkdir: dockerCase.containerWorkdir ?? dockerCase.hostWorkspacePath'); + }); + + it('reports whether the container is owned, on EVERY case-shaped response', () => { + // Two sites build a docker CaseInfo (the list and the single-case lookup); + // filling only one leaves the picker blind depending on which the UI read. + expect(routes.match(/owned: dockerCase\.owned !== false,/g) ?? []).toHaveLength(2); + }); + + it('treats an ABSENT owned flag as owned, so legacy cases are not offered', () => { + // `owned` is optional and predates this field; truthiness would read a legacy + // case as adopted and offer a duplicate the server then refuses. + expect(routes).toContain('dockerCase.owned !== false'); + }); +}); + +describe('the picker only offers what the server would accept', () => { + const fn = ui.slice(ui.indexOf('async _loadDockerCloneOptions()'), ui.indexOf('applyDockerCloneSource()')); + + it('filters to ADOPTED cases only', () => { + expect(fn).toMatch(/c\.docker\.owned === false/); + }); + + it('hides the row entirely when there is nothing to duplicate', () => { + expect(fn).toMatch(/row\.hidden = cases\.length === 0/); + }); + + it('builds options with textContent, never markup', () => { + // Case names and container names are user- and engine-supplied strings. + expect(fn).toContain('option.textContent ='); + expect(fn).not.toContain('innerHTML'); + }); +}); + +describe('applying a source fills what stays and clears what must differ', () => { + const fn = ui.slice(ui.indexOf('applyDockerCloneSource()'), ui.indexOf('applyDockerCloneSource()') + 1400); + + it('carries over container, host and workspace', () => { + for (const id of ['dockerContainerName', 'dockerHostId', 'dockerWorkspacePath']) { + expect(fn).toContain(`set('${id}', option.dataset.`); + } + }); + + it('clears the case name and the container workdir', () => { + // Keeping either would pre-fill a value the server is certain to refuse — + // the name as an existing case, the workdir as an exact twin. + expect(fn).toContain("set('dockerCaseName', '')"); + expect(fn).toContain("set('dockerAdoptWorkdir', '')"); + }); + + it('focuses the workdir, the field the user came here to change', () => { + expect(fn).toMatch(/getElementById\('dockerAdoptWorkdir'\)\?\.focus\(\)/); + }); + + it('does nothing for the blank "start from scratch" option', () => { + expect(fn).toMatch(/if \(!option \|\| !option\.value\) return;/); + }); +}); + +describe('the row is wired into the adoption panel', () => { + it('lives in the adopt-only block and starts hidden', () => { + expect(html).toMatch(/id="dockerAdoptCloneRow"[^>]*hidden/); + expect(html).toMatch(/class="form-row docker-adopt-only" id="dockerAdoptCloneRow"/); + }); + + it('loads its options whenever adopt mode turns on', () => { + // Slice from the DEFINITION, not the first call site. + const start = ui.indexOf('_syncDockerAdoptMode() {'); + expect(start).toBeGreaterThan(-1); + const sync = ui.slice(start, start + 900); + expect(sync).toContain('_loadDockerCloneOptions()'); + }); +});