mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
feat(docker): take run-mode availability from the container
The run-mode dropdown hides CLIs that are not installed on the HOST (#201). That is right for local sessions and wrong for a container case, whose agents run inside the container: a host with no claude installed hides the mode while the container ships one, which is exactly what happened on a real deployment. The adoption preflight already probes what the container has, so that result is persisted on the case and surfaced through CaseInfo. Docker cases gate on it; every other case keeps the host probe unchanged. An absent list reads as "do not gate" rather than "nothing available": an owned container runs our base image, which ships every CLI, and treating unknown as empty would leave the menu with Shell alone.
This commit is contained in:
@@ -167,6 +167,13 @@ export interface CaseInfo {
|
|||||||
image?: string;
|
image?: string;
|
||||||
path: string;
|
path: string;
|
||||||
network?: string;
|
network?: string;
|
||||||
|
/**
|
||||||
|
* CLIs available INSIDE the container. A container case runs its agents in
|
||||||
|
* the container, so HOST CLI availability says nothing about what it can
|
||||||
|
* run. Absent = unknown (an owned container runs our base image, which ships
|
||||||
|
* every CLI), which the UI reads as "do not gate".
|
||||||
|
*/
|
||||||
|
availableModes?: string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -261,6 +261,15 @@ export interface DockerCase {
|
|||||||
* all created by us).
|
* all created by us).
|
||||||
*/
|
*/
|
||||||
owned?: boolean;
|
owned?: boolean;
|
||||||
|
/**
|
||||||
|
* CLIs found INSIDE the container by the adoption preflight. A container case
|
||||||
|
* runs its agents in the container, so host CLI availability says nothing about
|
||||||
|
* what this case can run — the base image ships every CLI, and an adopted
|
||||||
|
* container ships whatever its owner installed. Absent = unknown (owned cases,
|
||||||
|
* or a case linked before this field existed), which callers read as "do not
|
||||||
|
* gate".
|
||||||
|
*/
|
||||||
|
availableModes?: SessionMode[];
|
||||||
/** Last captured Claude conversation id, replayed via --resume on a fresh launch. */
|
/** Last captured Claude conversation id, replayed via --resume on a fresh launch. */
|
||||||
lastClaudeSessionId?: string;
|
lastClaudeSessionId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -474,9 +474,23 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
|
* run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
|
||||||
*/
|
*/
|
||||||
_refreshRunModeAvailability(menu) {
|
_refreshRunModeAvailability(menu) {
|
||||||
|
// A DOCKER case runs its agents INSIDE the container, so host CLI
|
||||||
|
// availability answers the wrong question: the host may have no claude at
|
||||||
|
// all while the container ships one, and gating on the host hides a mode
|
||||||
|
// that would have worked. Adoption records what the container really has
|
||||||
|
// (`availableModes`); an owned container runs our base image, which ships
|
||||||
|
// every CLI, so an absent list means "do not gate" rather than "nothing".
|
||||||
|
// Same source every run* path reads the selected case from.
|
||||||
|
const caseName = document.getElementById('quickStartCase')?.value;
|
||||||
|
const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null;
|
||||||
|
const containerModes = activeCase?.location === 'docker' ? activeCase.docker?.availableModes : null;
|
||||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']) {
|
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']) {
|
||||||
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
|
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
|
||||||
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
|
if (!btn) continue;
|
||||||
|
let available;
|
||||||
|
if (activeCase?.location === 'docker') available = containerModes ? containerModes.includes(mode) : true;
|
||||||
|
else available = this.isCliAvailable(mode);
|
||||||
|
btn.style.display = available ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
// DeepSeek is the one mode whose availability has two halves: `dsh` can be
|
// DeepSeek is the one mode whose availability has two halves: `dsh` can be
|
||||||
// perfectly installed while no pane-capable profile exists, because DeepSeek
|
// perfectly installed while no pane-capable profile exists, because DeepSeek
|
||||||
|
|||||||
@@ -297,6 +297,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
image: host.image,
|
image: host.image,
|
||||||
path: dockerCase.hostWorkspacePath,
|
path: dockerCase.hostWorkspacePath,
|
||||||
network: host.network ?? 'bridge',
|
network: host.network ?? 'bridge',
|
||||||
|
...(dockerCase.availableModes ? { availableModes: dockerCase.availableModes } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const existingIndex = cases.findIndex((item) => item.name === dockerCase.name);
|
const existingIndex = cases.findIndex((item) => item.name === dockerCase.name);
|
||||||
@@ -851,15 +852,19 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not adoptable');
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not adoptable');
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]);
|
// Persist what the container actually has: the run-mode picker gates on
|
||||||
|
// HOST CLIs, which is the wrong question for a case whose agents run inside
|
||||||
|
// a container the host knows nothing about.
|
||||||
|
const adoptedCase = { ...dockerCase, availableModes: probe.availableModes };
|
||||||
|
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, adoptedCase]);
|
||||||
ctx.broadcast(SseEvent.CaseLinked, {
|
ctx.broadcast(SseEvent.CaseLinked, {
|
||||||
name: dockerCase.name,
|
name: adoptedCase.name,
|
||||||
path: dockerCase.hostWorkspacePath,
|
path: adoptedCase.hostWorkspacePath,
|
||||||
type: 'docker',
|
type: 'docker',
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: { case: dockerCase, image: probe.image, availableModes: probe.availableModes },
|
data: { case: adoptedCase, image: probe.image, availableModes: probe.availableModes },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -194,6 +194,34 @@ describe('adopted container: the Add Case panel id contract', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('adopted container: run modes come from the CONTAINER, not the host', () => {
|
||||||
|
const ui = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf8');
|
||||||
|
/** Slice the method BODY. Anchored on the definition, not a call site: the
|
||||||
|
* menu opener calls _loadRunModeHistory() ABOVE this definition, so slicing
|
||||||
|
* between call sites silently yields an empty string and passes nothing. */
|
||||||
|
const refreshFn = (src) => {
|
||||||
|
const start = src.indexOf('_refreshRunModeAvailability(menu) {');
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
return src.slice(start, start + 1600);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('gates a docker case on availableModes instead of host CLI probes', () => {
|
||||||
|
// The sandbox host had codex but no claude while the adopted container had
|
||||||
|
// claude and no codex; gating on the host hid the only mode that worked.
|
||||||
|
const fn = refreshFn(ui);
|
||||||
|
expect(fn).toContain("location === 'docker'");
|
||||||
|
expect(fn).toContain('availableModes');
|
||||||
|
// Non-docker cases must keep the original host probe (#201).
|
||||||
|
expect(fn).toContain('this.isCliAvailable(mode)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an owned container ungated when nothing was probed', () => {
|
||||||
|
// Our base image ships every CLI, so an absent list means "unknown", and
|
||||||
|
// treating unknown as "nothing available" would empty the menu.
|
||||||
|
expect(refreshFn(ui)).toMatch(/containerModes \?[^:]*:\s*true/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('adopted container: drift is not evaluated', () => {
|
describe('adopted container: drift is not evaluated', () => {
|
||||||
it('reports no drift rather than demanding a recreate we may not perform', async () => {
|
it('reports no drift rather than demanding a recreate we may not perform', async () => {
|
||||||
// An adopted container carries no codeman.confighash label, so a real
|
// An adopted container carries no codeman.confighash label, so a real
|
||||||
|
|||||||
Reference in New Issue
Block a user